VoIP & Telephony
The VoIP & Telephony module (id: voip, v1.0.0) delivers two complementary capabilities under one interface:
- Phone fleet management - discover, onboard, provision, reboot, factory-reset, and monitor SIP desk phones; push SIP credentials; track firmware compliance across the fleet.
- PBX integration - connect to a FreePBX or Asterisk PBX and manage extensions, trunks, ring groups, queues, IVR menus, DIDs, active calls, and call-detail records (CDR) without touching the PBX admin panel.
All API paths in this module are prefixed with /api/v1/voip.
Supported adapters
Section titled “Supported adapters”| Adapter | Transport | Notes |
|---|---|---|
| Grandstream | CGI/HTTP (challenge-response; CookieJar(unsafe=True) for IP URLs) | Phone lifecycle, provisioning, live-status, reboot, factory-reset, push-SIP |
| FreePBX / Asterisk | AMI + ARI + REST/OAuth2 | Full PBX management; Asterisk-only (no FreeSWITCH, 3CX, or other PBX types) |
| Yealink | HTTP | Discovery probe + provisioning XML generation (_generate_yealink_xml) - reboot, factory-reset, live-status, and push-SIP-config are Grandstream-only |
Discovery also detects Polycom phones by MAC OUI, but no adapter ships for Polycom.
Permissions
Section titled “Permissions”| Permission code | What it gates | Effective minimum role |
|---|---|---|
voip.view | Read phones, templates, fleet, PBX, voicemail | viewer |
voip.manage_phones | Add/edit/provision phones; PBX create/update/delete; trunk/extension writes; discovery scans; template CRUD | operator |
voip.manage_extensions | Create/delete ring groups | operator |
voip.manage_pbx | Declared; gates nav visibility only - PBX writes actually require voip.manage_phones | operator |
voip.view_calls | CDR search and active-call list | viewer |
voip.discovery | Declared; discovery write endpoints actually require voip.manage_phones | operator |
Two fleet operations are role-gated regardless of permissions:
| Operation | Minimum role |
|---|---|
POST /fleet/bulk/reboot | site_admin |
POST /fleet/bulk/firmware | site_admin |
Phone fleet (Grandstream)
Section titled “Phone fleet (Grandstream)”Phone lifecycle states
Section titled “Phone lifecycle states”Phones move through a defined lifecycle. You cannot skip states.
discovered → onboarding → managed → maintenance → firmware_updating → decommissionedStatus values (real-time): online, offline, ringing, in_call, dnd.
Provision status: pending, generated, pushed, applied, failed, stale.
Connecting a phone
Section titled “Connecting a phone”Before you can reboot, factory-reset, push SIP config, or read live status, a phone needs saved admin web credentials. Use the per-phone test-connection endpoint with save_credentials: true - this is the only endpoint that persists credentials to the phone record.
# Test connection and save credentials (phone record must already exist)curl -s -X POST https://<freesdn>/api/v1/voip/phones/<phone_id>/test-connection \ -H "Cookie: freesdn_access=<token>" \ -H "X-CSRF-Token: <csrf>" \ -H "Content-Type: application/json" \ -d '{ "ip_address": "10.0.1.101", "username": "admin", "password": "<web-admin-password>", "save_credentials": true }'To probe a phone before creating a record, use POST /phones/test-connection (no phone_id in the path). That ad-hoc endpoint accepts the same body fields (ip_address, username, password) but ignores save_credentials - it runs the probe and returns without persisting anything. To actually save credentials, you must use the per-phone endpoint above (/{phone_id}/test-connection) with the phone ID in the URL path.
If you skip credential saving, reboot/factory-reset/live-status/push-SIP return:
Phone has no saved admin credentials - run Test Phone Connection with 'Save credentials' enabled firstPhone endpoints
Section titled “Phone endpoints”| Method | Path | Purpose |
|---|---|---|
GET | /phones/ | List phones; filters: site_id, pbx_id, status, lifecycle_state, vendor, config_template_id, search; limit 1-500 (default 50) |
GET | /phones/stats | Count phones by status, lifecycle, vendor |
GET | /phones/{phone_id} | Single phone enriched with extension + PBX name |
POST | /phones/ | Create phone record |
PATCH | /phones/{phone_id} | Update phone; triggers device-registry sync |
DELETE | /phones/{phone_id} | Delete phone (204); removes from device registry |
POST | /phones/{phone_id}/onboard | Promote discovered → managed; assign PBX, extension, template, location, tags |
POST | /phones/{phone_id}/migrate | Move phone to another site (target_site_id, dry_run) |
POST | /phones/{phone_id}/reboot | Reboot via Grandstream adapter (202) - Grandstream only |
POST | /phones/{phone_id}/factory-reset | Factory-reset, wipes all config - Grandstream only |
GET | /phones/{phone_id}/live-status | Quick probe: phone_state, line_status, lockout, ts (~150-300 ms) - Grandstream only |
POST | /phones/{phone_id}/push-sip-config | Push extension SIP creds to phone - Grandstream only |
POST | /phones/{phone_id}/provision | Generate and push provisioning XML; records provision action |
GET | /phones/{phone_id}/config-preview | Preview generated provisioning XML (application/xml) |
POST | /phones/{phone_id}/test-connection | Full probe + login + optional credential save |
POST | /phones/test-connection | Ad-hoc connection test by IP (no existing phone record) |
PUT | /phones/{phone_id}/credentials | Save credentials for a phone |
POST | /phones/{phone_id}/decommission | Move phone to decommissioned lifecycle state |
POST | /phones/{phone_id}/maintenance | Toggle maintenance mode |
POST | /phones/auto-link | Auto-link discovered phones to FreePBX extensions by matching SIP registrar IP and user ID |
Provisioning XML
Section titled “Provisioning XML”Config generation (provisioning.py) writes XML to /data/provisioning/voip (maximum 128 KB per file). Vendor dispatch:
| Vendor value | Builder |
|---|---|
grandstream | _generate_grandstream_xml - full GDMS-style XML |
yealink | _generate_yealink_xml - template-based XML |
| anything else | _generate_generic_xml - minimal fallback |
Templates (ConfigTemplateCreate) carry: sip_settings, network_settings, provisioning_settings, feature_settings, line_key_settings (list), raw_overrides, firmware_version. Set is_default: true to apply a template automatically during discovery onboarding.
Provisioning endpoint auth (zero-touch)
Section titled “Provisioning endpoint auth (zero-touch)”Phones pull their config at boot from:
GET /api/v1/voip/provisioning/cfg{mac_address}.xmlThis endpoint has no cookie or JWT auth - the phone has no session. Instead it uses a two-factor device-auth scheme:
- Source-IP allowlist (LAN path) - the phone’s source IP (resolved from
request.client.host, never fromX-Forwarded-For) must fall inside one of the CIDRs listed inSite.subnets. This is the zero-touch path for phones on the managed LAN. - HMAC fallback - if the IP is outside all site subnets, the request must carry
?sig=<hex>(URL parameter) orX-Provisioning-Signatureheader equal toHMAC-SHA256(SECRET_KEY + ENCRYPTION_SALT, mac.lower()), constant-time compared.
Any failure returns a generic 404 (No config for this device) - the error is the same whether the MAC is unknown or the auth check failed, so an attacker cannot enumerate known phones.
Generate a signed URL for a phone from the onboarding flow using generate_provisioning_signature(mac).
Set provisioning_base_url in Settings → VoIP (e.g., http://freesdn:8000/api/v1/voip/provisioning) so the module can build the correct DHCP option-66 value.
Discovery
Section titled “Discovery”Run a network scan to find phones without manual registration.
# Trigger an async discovery scan (returns 202 + scan_id)curl -s -X POST https://<freesdn>/api/v1/voip/discovery/scan \ -H "Cookie: freesdn_access=<token>" \ -H "X-CSRF-Token: <csrf>" \ -H "Content-Type: application/json" \ -d '{ "scan_type": "full", "subnet": "10.0.1.0/24", "auto_onboard": false, "credentials": {"username": "admin", "password": "<default-pw>"} }'| Method | Path | Purpose |
|---|---|---|
POST | /discovery/scan | Start async Celery scan (202); credentials are transient and encrypted - never persisted |
GET | /discovery/scans | List scan history |
GET | /discovery/scans/{scan_id} | Full scan results |
GET | /discovery/scans/{scan_id}/status | Lightweight progress poll (phase, percent, last 20 log lines) |
POST | /discovery/scans/{scan_id}/cancel | Cancel running scan (best-effort SIGTERM via Celery revoke) |
DELETE | /discovery/scans/{scan_id} | Delete completed/failed/cancelled scan (204) |
scan_type values: full (all probes), arp, sip, http.
Vendor detection uses MAC OUI prefixes (Grandstream 000b82, c074ad, 7c2f80; Yealink 805ec0, 001565, 805e4a; Polycom 0004f2, 64167f) and HTTP fingerprint paths. Scan credentials are passed transiently and encrypted; they are not written to the database.
Fleet bulk operations
Section titled “Fleet bulk operations”| Method | Path | Purpose | Auth |
|---|---|---|---|
GET | /fleet/dashboard | Fleet metrics: counts by lifecycle, vendor, model, firmware, SIP-registered | voip.view |
POST | /fleet/bulk/reboot | Reboot 1-200 phones; all IDs validated org-wide in a single query - rejects the whole batch if any ID is foreign | role site_admin |
POST | /fleet/bulk/provision | Generate provisioning configs for a selection | voip.manage_phones |
POST | /fleet/bulk/firmware | Schedule bulk firmware upgrade (irreversible; supply target_version, schedule_at) | role site_admin |
POST | /fleet/bulk/connect | Bulk credential set + probe + persist for a list of phones | voip.manage_phones |
Firmware tracking
Section titled “Firmware tracking”| Method | Path | Purpose |
|---|---|---|
GET | /firmware/ | List registered firmware versions; filter by vendor, model |
POST | /firmware/ | Register a firmware version (201); download_url is SSRF-validated |
GET | /firmware/compliance | Fleet compliance report - phones not on a tracked stable version |
FirmwareTrackCreate requires vendor, model, version. Optional fields: release_date, changelog (max 10,000 chars), download_url (max 500 chars, SSRF-validated on creation), file_checksum (hex ≤128 chars), file_size_bytes (≤4 GB), is_stable, is_recommended.
Config templates
Section titled “Config templates”Templates define the provisioning XML structure applied to phones of a given vendor and model.
| Method | Path | Purpose |
|---|---|---|
GET | /templates/ | List templates with phone_count per template (single GROUP BY, no N+1) |
GET | /templates/{template_id} | Get one template |
POST | /templates/ | Create template (201) |
PATCH | /templates/{template_id} | Update template |
DELETE | /templates/{template_id} | Delete template (204) |
ConfigTemplateCreate fields: name, vendor (required), model_pattern (regex match against phone model string), is_default, plus JSONB blobs: sip_settings, network_settings, provisioning_settings, feature_settings, line_key_settings (list), raw_overrides, firmware_version.
PBX integration (FreePBX / Asterisk)
Section titled “PBX integration (FreePBX / Asterisk)”Connecting a PBX
Section titled “Connecting a PBX”The FreePBX adapter uses OAuth2 + GraphQL when api_client_id and api_client_secret are set (FreePBX 16+ Admin API). Without them it falls back to web-session + legacy AJAX.
# Register a PBX systemcurl -s -X POST https://<freesdn>/api/v1/voip/pbx/ \ -H "Cookie: freesdn_access=<token>" \ -H "X-CSRF-Token: <csrf>" \ -H "Content-Type: application/json" \ -d '{ "name": "pbx-main", "ip_address": "10.0.0.5", "pbx_type": "freepbx", "api_port": 443, "api_username": "admin", "api_password": "<pass>", "tls_verify_disabled_acknowledged": false }'tls_verify_disabled_acknowledged must be explicitly set to true if you want to skip TLS verification. Do not set it unless you have a specific reason; leave it false (the default) to enforce certificate validation.
After registering, run a full sync to pull extensions, ring groups, trunks, queues, IVR menus, and DIDs into the FreeSDN database:
# Kick off background sync (202); watch WS events on pbx.sync.*curl -s -X POST https://<freesdn>/api/v1/voip/pbx/<pbx_id>/sync \ -H "Cookie: freesdn_access=<token>" \ -H "X-CSRF-Token: <csrf>"WebSocket sync stages (subscribe to pbx.sync.* filtered by adapter_id='pbx:<pbx_id>'):
connecting → extensions → ring_groups → live_data → persisting → done
PBX system endpoints
Section titled “PBX system endpoints”| Method | Path | Purpose |
|---|---|---|
GET | /pbx/ | List PBX systems (credentials stripped) |
GET | /pbx/{pbx_id} | Get one PBX (sanitized) |
POST | /pbx/ | Create PBX (201) |
PATCH | /pbx/{pbx_id} | Update PBX |
DELETE | /pbx/{pbx_id} | Delete PBX (204) |
POST | /pbx/test-connection | Test connectivity to a PBX before saving |
POST | /pbx/{pbx_id}/sync | Full background sync (202); returns task_id |
POST | /pbx/{pbx_id}/connect | Full connection test using the adapter |
GET | /pbx/{pbx_id}/dashboard | Composite dashboard (DB counts + live AMI/ARI/REST status) |
GET | /pbx/{pbx_id}/system-info | Real-time system info via adapter |
PBXDashboard shows: ami_connected, ari_connected, rest_available, active_calls, voicemail counts.
PBX inventory reads (live, adapter-backed)
Section titled “PBX inventory reads (live, adapter-backed)”All reads require voip.view unless noted. All adapter responses are sanitized: *_password_enc columns dropped, redact_secrets (camelCase-aware) applied, and URLs in error messages stripped before surfacing.
| Method | Path | Purpose |
|---|---|---|
GET | /pbx/{pbx_id}/extensions | Extensions for this PBX with bound phone info |
GET | /pbx/{pbx_id}/trunks | SIP trunks (secret/auth_password redacted) |
GET | /pbx/{pbx_id}/trunks/{trunk_id} | Single trunk detail |
GET | /pbx/{pbx_id}/queues | Call queues |
GET | /pbx/{pbx_id}/ivrs | IVR menus |
GET | /pbx/{pbx_id}/dids | DIDs / inbound routes |
GET | /pbx/{pbx_id}/active-calls | Real-time active calls - requires voip.view_calls |
GET | /pbx/{pbx_id}/voicemail-boxes | Voicemail boxes |
GET | /pbx/{pbx_id}/config | Full synced config snapshot (redacted) |
GET | /pbx/{pbx_id}/outbound-routes | Outbound routes |
GET | /pbx/{pbx_id}/ring-groups | Ring groups for this PBX |
GET | /pbx/{pbx_id}/call-logs | Live CDR via adapter - requires voip.view_calls; params: start_date, end_date, src (≤64), dst (≤64), limit 1-1000 |
GET | /pbx/{pbx_id}/followme | Follow-Me entries |
GET | /pbx/{pbx_id}/announcements | Announcements |
GET | /pbx/{pbx_id}/paging | Paging/intercom groups |
GET | /pbx/{pbx_id}/daynight | Day/night call-flow controls |
GET | /pbx/{pbx_id}/blacklist | Blacklisted numbers |
GET | /pbx/{pbx_id}/certificates | SSL/TLS certificates |
GET | /pbx/{pbx_id}/admin-users | FreePBX AMP admin users (redacted) |
Extensions
Section titled “Extensions”| Method | Path | Purpose |
|---|---|---|
GET | /pbx/{pbx_id}/extensions/{ext_number} | Single extension (ext_number: digits, 1-20 chars) |
POST | /pbx/{pbx_id}/extensions | Create extension (201) |
PATCH | /pbx/{pbx_id}/extensions/{ext_number} | Update extension |
DELETE | /pbx/{pbx_id}/extensions/{ext_number} | Delete extension (204) |
GET | /extensions/ | List extensions across all PBX systems; filter by site_id |
ExtensionCreate fields - required: extension_number (digits, 1-20 chars), display_name (1-255 chars); optional: caller_id_name, caller_id_number, voicemail_enabled (default true), voicemail_pin (4-10 digits), password (8-128 chars, default null), settings.
Trunks
Section titled “Trunks”| Method | Path | Purpose |
|---|---|---|
POST | /pbx/{pbx_id}/trunks | Create SIP trunk (201) |
PATCH | /pbx/{pbx_id}/trunks/{trunk_id} | Update SIP trunk |
DELETE | /pbx/{pbx_id}/trunks/{trunk_id} | Delete SIP trunk (204) |
Trunk bodies are free-form dicts (PJSIP/chan_sip options vary too much to enumerate). Validation enforces: ≤128 keys, ≤4,096 chars per string value, ≤64 KB total body size.
Ring groups
Section titled “Ring groups”| Method | Path | Purpose |
|---|---|---|
GET | /ring-groups/ | List ring groups; filter by pbx_id, site_id |
POST | /ring-groups/ | Create ring group (201) - requires voip.manage_extensions |
DELETE | /ring-groups/{ring_group_id} | Delete ring group (204) - requires voip.manage_extensions |
RingGroupCreate fields: pbx_id, group_number (digits, 1-20 chars), name, ring_strategy (ringall, ringall-prim, hunt, memoryhunt, firstavail, firstnotonphone, random, rrmemory, rrordered; default ringall), ring_time 1-300 s (default 20), members (list of extension numbers), settings.
Live call control
Section titled “Live call control”| Method | Path | Body | Purpose |
|---|---|---|---|
POST | /pbx/{pbx_id}/call/originate | {extension, destination, caller_id, context} | Originate outbound call; context must match ^from-internal(-additional)?$ |
POST | /pbx/{pbx_id}/call/hangup | {channel} | Hang up an active channel |
POST | /pbx/{pbx_id}/call/transfer | {channel, destination, context} | Transfer active call to another extension |
POST | /pbx/{pbx_id}/reload | - | Apply pending config (equivalent to FreePBX “Apply Config”; briefly interrupts active calls) |
POST | /pbx/{pbx_id}/queue/add-member | {queue_name, interface, member_name} | Add member to a call queue |
POST | /pbx/{pbx_id}/queue/remove-member | {queue_name, interface, member_name} | Remove member from a call queue |
queue_name must match ^[\w\-]+$. The FreePBX adapter enforces allowed_outbound_prefixes when originating calls.
CDR and call history
Section titled “CDR and call history”The module exposes two distinct CDR sources that can diverge:
| Endpoint | Source | Notes |
|---|---|---|
GET /call-logs/ | DB-stored call_logs table | Populated by background sync; filters: pbx_id, start_time, end_time, direction, call_status, caller, callee, site_id; limit 1-1,000 |
GET /call-logs/stats | DB-stored call_logs table | Aggregate stats for a time range |
GET /pbx/{pbx_id}/call-logs | Live adapter CDR | Real-time query to FreePBX; result may differ from DB until next sync |
Call direction values: inbound, outbound, internal.
Call status values: answered, missed, voicemail, failed.
Both CDR endpoints require voip.view_calls.
Voicemail
Section titled “Voicemail”| Method | Path | Purpose |
|---|---|---|
GET | /voicemails/ | List voicemail messages; filter by extension_number, folder, is_read, site_id |
GET | /voicemails/stats | Voicemail statistics |
GET | /voicemails/{vm_id} | Get single voicemail record |
PATCH | /voicemails/{vm_id} | Mark read / move to folder |
POST | /voicemails/{vm_id}/mark-read | Mark as read |
DELETE | /voicemails/{vm_id} | Delete voicemail record (204) |
GET | /voicemails/{vm_id}/download | 501 Not Implemented - see below |
Module settings
Section titled “Module settings”Configure in Settings → VoIP in the UI, or via the module settings API.
| Setting | Default | Notes |
|---|---|---|
default_codec | g711u | One of g711u, g711a, g722, g729, opus |
sip_port | 5060 | SIP signaling port |
rtp_port_start | 10000 | RTP media port range start |
rtp_port_end | 20000 | RTP media port range end |
cdr_retention_days | 90 | 30-365; CDR pruned beyond this |
discovery_default_subnet | "" | Pre-fill in the Discovery UI |
provisioning_base_url | "" | E.g. http://freesdn:8000/api/v1/voip/provisioning; used to build DHCP option-66 values |
auto_provision_on_discovery | false | Auto-push provisioning config when a phone is discovered |
health_check_interval | 300 | Seconds between phone health polls (60-3600) |
The provisioning auth HMAC key derives from SECRET_KEY + ENCRYPTION_SALT. Both must be set in your environment (see Deployment).
Background tasks (Celery)
Section titled “Background tasks (Celery)”The module registers these Celery tasks on startup:
| Task name | Purpose |
|---|---|
voip.sync_phones | Sync phone state from adapters |
voip.sync_cdr | Pull CDR from PBX into DB |
voip.sync_extensions | Sync extensions from PBX |
voip.generate_provisioning_files | Regenerate stale provisioning XML |
voip.poll_extension_states | Poll live extension registration status |
voip.reboot_phone | Async single-phone reboot |
voip.run_discovery_scan | Execute a discovery scan scan (credentials transient, never persisted) |
voip.health_check | Module health probe |
voip.check_firmware_compliance | Compare phone firmware against tracked stable versions |
voip.bulk_reboot | Coordinate fleet-wide reboot |
voip.sync_pbx_full | Full PBX sync backing POST /pbx/{id}/sync |
Fabric integration
Section titled “Fabric integration”The VoIP module exposes one read operation in the Fabric catalog:
voip.phone.live_status- NATIVE tier, inputphone_id, permissionvoip.view
Emitted events (subscribable via Fabric Connections):
pbx.sync.started, pbx.sync.progress, pbx.sync.completed, pbx.sync.failed, pbx.originate_call.ok, pbx.originate_call.failed, pbx.reload.ok, pbx.reload.failed, phone.provision.ok, phone.provision.failed, phone.reboot.ok, phone.reboot.failed.
Backup behavior
Section titled “Backup behavior”The Configuration Backup module captures VoIP configuration automatically:
- Included: PBX records, extensions, ring groups, config templates
- Excluded: all credentials - phone web passwords, PBX API credentials, SIP trunk secrets
This is intentional; credentials in a portable archive are a credential-exfil risk. Restore a backup and then re-enter credentials.
Security notes
Section titled “Security notes”- Credential storage: phone admin web passwords are Fernet-encrypted at rest. PBX API and AMI/ARI credentials are also encrypted. All adapter responses run through
redact_secrets(89 sensitive key patterns, camelCase-aware) before returning to the client. - Cross-tenant isolation: cross-tenant references (a phone, PBX, or extension belonging to another org) return 404 rather than 403 to avoid leaking foreign-row existence. Bulk operations that include any foreign phone ID are rejected with 403 for the entire batch.
- Provisioning endpoint: returns a generic 404 for both unknown MACs and failed auth - the error does not distinguish the two cases.
- Error URL redaction: httpx errors embed PBX URLs with credentials in the message. The API redacts these with
re.sub(r"https?://\S+", ...)before surfacing errors. - FreePBX TLS: you must explicitly set
tls_verify_disabled_acknowledged: trueto skip TLS verification. The default isfalse(verification on). - Outbound dialing: the FreePBX adapter enforces
allowed_outbound_prefixeson call-originate requests as a toll-fraud guard.
Frontend pages
Section titled “Frontend pages”| Page | Route | Notes |
|---|---|---|
| Fleet dashboard | /voip | Default landing; fleet metrics and health at a glance |
| Phone list | /voip/phones | Filters, bulk connect/reboot/provision |
| Phone detail | /voip/phones/:id | Test connection, push SIP, reboot/factory-reset, live status, config preview |
| Discovery | /voip/discovery | Trigger and monitor scans; onboard results |
| Config templates | /voip/templates | Template CRUD |
| Firmware tracking | /voip/firmware | Stub page - use the API for now |
| PBX list | /voip/pbx | PBX system list |
| PBX detail | /voip/pbx/:id | 11 tabs: Overview, Extensions, Trunks, Ring Groups, Queues, IVR, DIDs, Active Calls, Voicemail, Config, Settings |
| Extensions | /voip/extensions | Cross-PBX extension list and ring groups |
| Call logs | /voip/calls | CDR search |
| Voicemail | /voip/voicemail | Voicemail inbox (audio download button disabled - 501) |
Nav visibility is permission-gated: Discovery, Templates, and Firmware require voip.manage_phones; PBX Systems requires voip.manage_pbx; Ring Groups requires voip.manage_extensions; Call Logs and Active Calls require voip.view_calls; the rest require voip.view.
Next steps
Section titled “Next steps”- Deployment - environment configuration - set
SECRET_KEY,ENCRYPTION_SALT,FORWARDED_ALLOW_IPSbefore provisioning phones - Configuration Backup - understand what VoIP data is captured and what is excluded from backups
- Firewall - SIP ALG and port-forwarding considerations if phones are behind NAT
- Permissions reference - full RBAC role hierarchy and how to assign permissions
- Fabric catalog - subscribe to VoIP events in Connections and n8n workflows