Files
selfphoneng/docs/spec/08-security-ops.md
T
iamdoubz 324f9d9fe2
ci / check (push) Successful in 56s
ci / images (push) Successful in 23s
docs: admin guide (install, voip.ms, network, E911, troubleshooting, updating)
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
2026-10-20 08:57:54 -05:00

9.7 KiB
Raw Blame History

08 — Security, operations, legal

1. Threat model (what attacks a PBX on the internet)

Threat Example Controls
SIP scanners / password guessing "friendly-scanner", sipvicious hitting 5060 Generated 24-char SIP passwords; no anonymous endpoint; fail2ban/CrowdSec on security.log; optional phone ACL (§2.2); firewall allow-list
Toll fraud (the expensive one) Stolen SIP creds used to call premium international numbers International route disabled by default; per-user outbound class; max_channels; voip.ms-side international block + balance limits (06 §1); forward targets checked against class
Web credential stuffing Login brute force argon2id, rate limits, audit log, TOTP for admins (M11)
Voicemail PIN guessing Calling *98 and trying PINs 6-digit default PIN, maxlogins=3, reject trivial PINs (000000, 123456, the extension number repeated). As built (M11-T1): also for PINs changed from the phone's voicemail menu: externpasscheck = /usr/local/bin/sp-vm-pincheck (asterisk image, same rules, a unit test keeps it in step with isTrivialPin), minpassword = 4; the api stores accepted ones from AMI VoicemailPasswordChange (services/vmpin.ts), otherwise the next config write would put the old PIN back
Provisioning leaks Fetching /prov/<mac>.cfg to steal SIP passwords Per-device Basic auth, 404 on unknown MAC, rate limit + temporary IP block, optional LAN-only
Dialplan injection Name x); System(...) Sanitizers (03 §2), no System() anywhere
SSRF via webhooks Webhook to http://127.0.0.1:5038 Private IP block by default (04 §9)
Stolen backup Backup file copied Backups exclude generated plaintext configs; DB secrets are encrypted with APP_SECRET (kept in .env, not in the backup); mode 0600
AMI exposure AMI reachable from network Bound to 127.0.0.1, permit 127.0.0.1 only, random secret

2. Required controls

2.1 Network

  • Expose only 443/tcp, 5060/udp+tcp (5061/tcp if TLS), 10000–20000/udp.
  • Admin guide includes an nftables example that limits 5060/5061 to voip.ms server IPs (from the voip.ms wiki list, verify) plus sip.extraAllowedNets, for setups with no remote phones.
  • As built (M11-T2), SIP over TLS:
    • Certificate: data/tls/fullchain.pem + privkey.pem, mounted read-only into asterisk and the api (TLS_DIR). scripts/tls-cert.sh copies them from the bundled Caddy's storage (data/caddy/caddy/certificates/*/<PUBLIC_HOSTNAME>/) or from two given paths (certbot), owner app UID, key 0600, and runs module reload res_pjsip.so. Asterisk 24 re-reads the cert and key on that reload (same file names, and only when a file's mtime changed; verified, calls stay up), so no restart. It does nothing when the files are unchanged: run it daily from cron.
    • Asterisk quietly leaves transport-tls out when the files are missing, so PATCH /api/settings refuses to turn sip.tlsEnabled on unless both load and the key matches (node:crypto X509Certificate.checkPrivateKey), and the dashboard warns (tls_cert) when TLS is on and the cert is missing or expires within 14 days.
    • transport-tls has verify_server = yes + ca_list_file = /etc/ssl/certs/ca-certificates.crt: the provider's certificate is checked (voip.ms: Let's Encrypt, every POP in its SAN list, checked 2026-10-19). Asterisk 24.0.0-rc2 checks a qualify OPTIONS against the IP instead of the host name, which fails on voip.ms ("server identity does not match") and marks the contact Unavailable, so TLS trunks get qualify_frequency = 0 and Test skips the qualify; REGISTER and INVITE verify the name correctly. Re-check on Asterisk upgrades.
    • Phones over TLS are not provisioned (templates stay UDP); that's for remote phones later.

2.2 Phone ACL (setting sip.restrictPhonesToLocalNets)

When true, the generator adds to every desk extension endpoint (not -web, not trunks):

deny = 0.0.0.0/0.0.0.0
permit = 192.168.0.0/16                ; each network.localNets + sip.extraAllowedNets (deduplicated)

Golden case phone-acl; scripts/e2e/m11-t1.mjs shows Asterisk refusing a REGISTER ("Not match Endpoint ACL") and accepting it once the network is listed.

Changed from the first draft (M11-T1): contact_acl = sp-phone-acl names a section of acl.conf, not pjsip.conf, and a type = acl section in pjsip.conf is res_pjsip_acl's global ACL, which would filter trunks too. Contact-header rules alone are also weak: a phone behind NAT at home advertises a private address. Source-address deny/permit on the endpoint is what keeps remote phones out. Browser phones arrive through the reverse proxy (their source is the proxy), so -web endpoints aren't filtered. The setting is on Settings → Security, with the provisioning switches (07 §2).

2.3 fail2ban / CrowdSec

  • logger.conf writes security.log (01 §3.3).
  • Admin guide: fail2ban asterisk jail pointed at ./data/asterisk-logs/security.log, or the CrowdSec Asterisk collection with the same log path (verify collection name).
  • The api's own auth failures go to stdout JSON with event: "auth.failed" and ip, so they can be parsed too.

2.4 Web

  • Security headers on every api response: Content-Security-Policy: default-src 'self'; connect-src 'self' wss://{host}; media-src 'self' blob:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none', X-Content-Type-Options: nosniff, Referrer-Policy: same-origin, Permissions-Policy: microphone=(self), camera=().
  • HSTS is set by the reverse proxy (Caddy does it by default; nginx example adds add_header Strict-Transport-Security "max-age=31536000" always;).
  • TRUST_PROXY=true → Fastify trustProxy: '127.0.0.1' only.
  • Admin TOTP (M11): RFC 6238, 30 s, 6 digits, ±1 window, 10 single-use recovery codes (stored hashed).
  • As built (M11-T1), TOTP (auth/totp.ts, node:crypto only, RFC 6238 test vectors in its test): opt-in for admins on My account (header → Account): scan the QR or type the key, confirm one code, get 10 recovery codes once (k7m3q-x9tpw; case and dashes don't matter). Login asks for the code only after the password is right (401 totp_required, no session); a wrong code is a failed login (rate limit, audit). Each 30 s step signs in once (users.totp_last_step, updated atomically) and each recovery code once (array_remove with an any() guard). Turning it off or making new codes needs the password. Another admin can turn it off for a lost phone (POST /api/users/:id/reset-two-step, signs that user out). scripts/e2e/m11-t1-totp.mjs.
  • As built (M11-T1): the headers come from an onSend hook on every response (UI, API, /prov); connect-src also allows WEBRTC_WS_URL's origin when it's set. The dark-mode bootstrap moved from an inline <script> in index.html to /theme.js, so script-src stays 'self'.

2.5 Files and permissions

  • /etc/asterisk/selfphoneng/* mode 0640, owner app UID.
  • .env mode 0600 (install.sh sets it).
  • Recordings and voicemail are served only through authenticated api routes, never as static files.

3. Operations

3.1 Install / update

  • scripts/install.sh (01 §1.3).
  • scripts/update.sh: git pull --ff-only, docker compose build, docker compose up -d. Migrations run automatically on api start. Print the new version. 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.

3.2 Monitoring

  • GET /healthz for an external monitor (Uptime Kuma etc.): 200 / 503 + JSON reasons.
  • Emails to admins: trunk down > 5 min, disk < 10%, public IP changed, backup failed.
  • Logs: docker compose logs api, ./data/asterisk-logs/messages.log.

3.3 Troubleshooting page in the admin guide (M11)

Must cover: one-way audio (NAT/publicIp/SIP ALG/RTP ports), trunk won't register (credentials, server, IP whitelist when IP auth, firewall), inbound calls don't arrive (DID routing + POP), browser softphone won't register (WSS proxy path /ws, certificate, microphone permission), voicemail emails not sent (SMTP test), and how to use /admin/system CLI views and pjsip set logger on (via docker compose exec asterisk asterisk -rx).

4. Licensing compliance

  • Our code: AGPL-3.0-or-later. Add LICENSE, SPDX headers, and an About page (/admin/system → About) listing the license and a link to the source repository (AGPL requires offering source to network users).
  • Asterisk: GPL-2.0, runs as a separate program. We don't link to it, so there's no license conflict.
  • Asterisk core/extra sounds and opsound MOH: CC-BY-SA. Credit them on the About page.
  • Ringtone: CC0, documented in apps/web/public/sounds/SOUNDS-LICENSE.md.
  • No closed-source codecs (AGENTS rule 4).
  • Call recording consent: US federal law and most states are one-party consent, but several states (e.g. California, Florida, Illinois, Maryland, Massachusetts, Montana, New Hampshire, Pennsylvania, Washington) require all parties to consent. When any recording is enabled, the Recording settings tab shows this notice and strongly suggests setting recordings.announcementSoundId. The setup wizard doesn't turn recording on.
  • E911 / Kari's Law / RAY BAUM'S Act: see 06 §6.
  • Data retention: retention settings for recordings and voicemail (04 §8). Deleting a user offers "delete their voicemail and recordings too".