All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog.
[2.7.3] - 2026-09-18
Changed
- BREAKING: relicensed from PolyForm Shield 1.0.0 to AGPL-3.0-or-later —
LICENSE/NOTICE/README.md/CONTRIBUTING.mdand theSPDX-License-Identifierheader incore/conf.py,core/models.py,manage.py,pbx/settings.pynow reflect AGPL-3.0. The previous “no competing product/service” restriction is gone; the AGPL’s network-use copyleft applies instead — anyone running a modified version as a network service must make that version’s complete source available under the same license. A CLA is now required for external contributions:.github/workflows/cla.yml(contributor-assistant/github-action) posts a sign-off prompt on a contributor’s first PR, backed by newCLA.md.
Added
update-asterisk.sh: in-place Asterisk version upgrade with backup and rollback — until now, bumpingasterisk_versioninansible/group_vars/all.ymldid nothing on an already-installed host (ansible/roles/asterisk/tasks/main.yml’s build block only ran when/usr/sbin/asteriskwas absent), so there was no supported way to move a production host from one Asterisk version to another. The newansible/roles/asterisk_updaterole: backs up/etc/asterisk, the binaries/modules/headers, and/var/lib/asteriskto a timestamped directory under/var/backups/asterisk-upgrade/(ledger atstate.json); compiles the target version while the current Asterisk keeps serving calls, aborting before anything is touched if the build fails; gracefully drains active calls (core stop gracefully, configurable timeout,--no-waitfor an immediate stop); runsmake installonly, so configs/sounds/logs/spool/recordings are never touched; and restarts Asterisk plus the AMI-connected services, verifyingasterisk -rx "core show version"reports the target before declaring success.update-asterisk.sh --rollbackrestores the previous binaries from the backup (--restore-configs/--restore-soundsopt into restoring those too, since configs are DB-generated and a blind restore could discard later admin changes);-l/--listshows the upgrade history; mirrors the existingupdate.sh/rollback.shcode-deployment workflow. The asterisk role’s build steps (download/configure/menuselect/compile) and ownership-fixup loop are now shared between the install and upgrade paths via newroles/asterisk/tasks/{build,permissions}.yml, and the extracted-directory lookup is now the deterministicasterisk-{{ asterisk_version }}path instead of an unsorted glob that could pick the wrong version when two source trees coexisted. Documented indocs/{en,ua,es}/admin-guide.md.- Automatic TLS certificate provisioning for nginx (WebRTC wss) — the nginx Ansible role now provisions a working HTTPS/
wss://certificate on a fresh install with no manual step, in three tiers: (1) a domain is given (--domain) → Let’s Encrypt via HTTP-01 webroot, 90-day cert, standard renewal; (2) no domain but a public IPv4 is detected → Let’s Encrypt’s short-lived IP-address certificate (--preferred-profile shortlived), 6-day validity renewed every 4h by a systemd timer, falling back to an external IP lookup for NAT’d cloud hosts with no locally-visible public address; (3) neither is reachable → a self-signed fallback, now with a propersubjectAltName(previously CN-only, which modern browsers/clients reject) and capped at 397 days (Apple’s 398-day hard limit, was 3650).certbotis installed into a dedicated pip venv at/opt/certbotsince Debian/Ubuntu ship certbot 2.x and IP-address certs require ≥ 5.4. The vhost’sssl_certificate/ssl_certificate_keyalways point at stable symlinks, redirected to whichever source is active, so nginx config never needs to know which tier provisioned the cert.install.sh/quickstart-install.shgained--domain/--emailflags (andPEARLPBX2_DOMAIN/PEARLPBX2_EMAILenv vars),ALLOWED_HOSTS/CSRF_TRUSTED_ORIGINSinclude the domain when set, and the closing install message reflects which tier ran (read from/etc/PearlPBX/tls-mode). - Quickstart now seeds 10 WebRTC (wss) demo users —
seed_quickstartcreates atransport-wssSIPTransportandwebrtcuser1..webrtcuser10SIPUsers (each a random 16-char password) on theOutgoingrouting table, so a fresh install has working browser-calling test accounts out of the box. Under Docker (PEARLPBX2_DOCKER=1), the seeded users getdisallow=all/allow=g722,ulaw,vp9,vp8,h264custom settings instead of the default Opus-first allow list — theandrius/asterisk:22image ships the Opus SDP/runtime library but not thecodec_opus.sotranslator, so negotiating Opus there fails everyPlayback()with “Unable to find a codec translation path”; bare-metal/Ansible installs are expected to have Opus compiled in and are unaffected. Settings.local_users_dial_template— the per-user AEL body used bycore.conf.make_local_users_context()for every active SIP user’s entry in the auto-generatedPEARLPBX-Userscontext is now an editable template (migration0082_settings_local_users_dial_template.py), with${SIPUser.username}substituted per user before rendering (other${...}placeholders are native Asterisk variables and pass through unchanged). This replaces the previous approach of writing oneDialplanExtensionrow perSIPUseras asave()/delete()side effect — a design that silently missed anySIPUsercreated through a Django migration or bulkbulk_create(), since Django’s historical models used during migrations bypass customsave()logic entirely. Migration0083_repoint_pearlpbx_local_users.pycleans up the now-unused stored rows. See also thePEARLPBX-Userscontext protection entry below, which builds on the samemake_local_users_context().- SIP User admin form shows the realm and MD5 credential (HA1) —
SIPUserAdmingained read-onlyrealm_display/md5_cred_displayfields next tosecret, so an admin can copy the RFC 2617 HA1 hash (MD5(username:realm:password)) that WebRTC/hardware-phone provisioning sometimes needs, without opening a shell. fastagidial-trunk-groupAGI: configurable dial timeout andDial()options — the AGI previously hardcodedDial(..., "120", "rtT")on every retry attempt; it now accepts two additional positional AGI arguments (dial timeout in seconds,Dial()options string), defaulting to newDIAL_TRUNK_GROUP_TIMEOUT/DIAL_TRUNK_GROUP_OPTIONSenv vars (120 /tT) when the dialplan call omits them. Documented inservices/fastagi/README.md.makeconvenience targets for Docker —make docker-up(background, no dev override),make docker-dev(foreground, hot reload),make docker-stop,make docker-clean, plus amake helpdefault target listing all targets.- Docker Asterisk container hardening — a real
healthcheck(asterisk -rx "core show channels count") now gates thedjangoservice’sdepends_on, so Django no longer starts against an Asterisk that hasn’t finished booting;docker/asterisk-bootstrap.shalso now loadsres_srtp.so(missing from the vendor image’s defaultmodules.conf, and required for WebRTC’s DTLS-SRTP — without it every wss call failed to negotiate media) and downloads the officialasterisk-core-sounds-en-gsmpackage into a newasterisk_soundsnamed volume, since the vendor image ships/var/lib/asterisk/soundscompletely empty (no stock prompts at all, sodemo-echotest,agent-loginok, thequeue-*prompts, etc. were silent). The named volume also fixes a pre-existing bug where the image’s ownVOLUME /var/lib/asterisk/soundsdeclaration shadowed that subpath of the broaderasterisk_libmount with an anonymous volume, making custom sound files invisible to Asterisk.contrib/configs/http.conf’sbindaddrchanged from127.0.0.1to0.0.0.0(PJSIP’swsstransport rides Asterisk’s built-in HTTP server, which must be reachable from outside the container), anddocker-entrypoint.shnow runsseed_quickstart/apply_changesonly once, gated on a marker file on the sharedasterisk_configvolume, mirroringansible/install.yml(a redeploy previously re-ran the full quickstart seed on every container start). - REST API: CRUD for
DialplanContext,DialplanExtension,DialplanMacro— closes the largest remaining API gap: the dialplan text that every rename/delete guard (TrunkGroup,Queue) already scans could previously only be edited through the admin.apps/api/views/dialplan.pyexposes/api/v1/dialplan-contexts/,/api/v1/dialplan-extensions/,/api/v1/dialplan-macros/(staff-only). All three models now inheritAuditFields(migration0089_dialplan_audit_fields.py), which also addsvalidators=[validate_ael_variable_name]toDialplanMacro.name(it previously accepted any string, including hyphens/spaces that break the generatedmacro name() {AEL block).DialplanMacrogained the same three-layer dialplan-reference guard asTrunkGroup/Queue:DialplanMacro.find_dialplan_references()scansDialplanExtension.dialplan, other macro bodies, andSettings.local_users_dial_templatefor a literal&name();AEL macro call (case-sensitive, since AEL resolves macro calls by exact name — unlikeQueue’s case-insensitivestrcasecmp()lookup). Renaming or deleting a macro still called that way is rejected at the model level (clean()/delete()), the admin (DialplanMacroAdminvia the existingDialplanGuardedDeleteAdminMixin), and the API (400/409via the existingDialplanGuardedDestroyMixin). This is a best-effort text scan — it can’t see a call reached only through an Asterisk variable, and it can’t see the Python-levelDEFAULT_LOCAL_USERS_DIAL_TEMPLATEfallback used when noSettingsrow exists or the field is blank.- The auto-generated
"PEARLPBX-Users"context is now protected. It’s rendered live fromSIPUserdata bycore.conf.make_local_users_context()and excluded frommake_dialplan_contexts()— aDialplanExtensionstored inside it would silently never reachextensions.ael, and renaming the row would make it render twice under two names (the live version plus a stale one from its old stored extensions).DialplanContext.clean()/delete()now reject renaming or deleting this reserved context, andDialplanExtension.clean()now rejects creating an extension inside it (previously only checked byDialplanExtensionSerializer.validate_context()at the API layer, leaving the admin’sDialplanExtensionFormable to save one silently).DialplanContextAdmin.has_delete_permission()hides the delete button. DialplanContext.delete()now also guardsWebhook.contexts, a plain M2M with noPROTECT— mirroring the existingRoutingTable.delete()guard forWebhook.routing_tables. Deleting a context still referenced byDialplanExtension.context/RoutingRecord.context(bothPROTECT) already returned409for free via the existingProtectedErrorbranch inapps.api.exceptions.DialplanExtensionSerializer.contextis required even though nullable in the DB, for the same reason asRoutingRecordSerializer.context—make_dialplan_contexts()only emits extensions grouped under a context. Itsdialplanfield’s validation (Asterisk AEL syntax, and that any¯o();call references an existingDialplanMacro) comes for free from the model’s existingvalidate_dialplan_field; create a macro before an extension that calls it.(context, ext)uniqueness uses an explicitUniqueTogetherValidatorrather than DRF’s automaticMeta.constraintsdetection, since the latter injectsdefault=Nonefor the nullablecontextfield, which conflicts with the serializer’s ownrequired=Trueoverride.- Documented in
docs/{en,ua,es}/API.md.
- REST API: CRUD for
PhoneDevice,Queue,QueueMember— three new staff-only resources:apps/api/views/phone_devices.py’sPhoneDeviceViewSetexposes/api/v1/phone-devices/.mac_addressvalidation (accepts separators/mixed case, normalizes toXX:XX:XX:XX:XX:XX) moved fromPhoneDeviceForm.clean_mac_address()to a reusablecore.validators.validate_mac_address(). A newPOST /api/v1/phone-devices/<id>/provision/action wrapsPhoneProvisioningManager.provision_device()— the same thing the admin’s “Apply configurations” button does, for one device over the API.apps/api/views/queues.pygainedQueueViewSet/QueueMemberViewSet(DB config) alongside the existing AMI-backedQueueMemberPauseView/QueueMemberListView(live runtime state) — the module docstring and each resource’s docs now spell out the difference, sinceGET /api/v1/queue-members/andGET /api/v1/queues/members/both answer “who’s in this queue” from entirely different sources of truth.Queue/QueueMembernow inheritAuditFields(migration0088_queue_queuemember_audit_fields.py);QueueAdmin/QueueMemberAdminuseAuditAdminMixin.Queue.nameis looked up by Asterisk’sapp_queuefrom a literalQueue(<name>,...)AEL app-call embedded in dialplan text — renaming or deleting a queue whileQueue.find_dialplan_references()finds such a reference is rejected at the model level (clean()/delete()), the admin (QueueAdmin.has_delete_permission()), and the API (400/409), mirroringTrunkGroup’s existing guard. Unlike that guard, the match is case-insensitive (re.IGNORECASE) —app_queueresolves queue names withstrcasecmp(), so a differently-cased dialplan call really does reach the queue.QueueSerializer.strategyis required even though nullable in the DB —core.conf._make_single_queue_config()interpolates it with no null guard (f"strategy={queue.strategy}"), so an empty value would literally emitstrategy=Noneintoqueues.conf.QueueMemberSerializer.interfacereusesvalidate_asterisk_interface(previously only applied to the AMI pause endpoint, never to the model itself);member_namedefaults to""instead of allowingnull, since a null value renders as the literal wordNonein the generatedmember => ...line. A given(queue, interface)pair must be unique (UniqueTogetherValidator,400on conflict).- Documented in
docs/{en,ua,es}/API.md.
- REST API: CRUD for
RoutingRecord—apps/api/views/routing_records.py’sRoutingRecordViewSetexposes prefix-based routing rules under/api/v1/routing-records/(staff-only, like the other resources in this API).RoutingRecordnow inheritsAuditFields(migration0087_routingrecord_audit_fields.py).context/routing_tableare required at the API layer even though nullable in the DB —core.conf.make_routing_tables()interpolatescontextwith no null guard (a null value would literally emitgoto None,${EXTEN},1;into the generated dialplan), and a record with norouting_tablenever appears in any table’s generated block.?name=,?prefix=,?routing_table=,?context=,?search=filters. Documented indocs/{en,ua,es}/API.md. - REST API:
secret/md5_cred/priv_key_fileare hidden in list responses —SIPUserSerializer/SIPPeerSerializer(secret,md5_cred) andSIPTransportSerializer(priv_key_file) now returnnullfor these fields onGET /api/v1/sip-users/,/sip-peers/,/sip-transports/(the paginated list), via a newHideSensitiveInListMixinkeyed offview.action == "list". The real value is still returned byGET .../<id>/and by the objectPOST/PATCHreturn — this only keeps credentials out of a bulk dump.cert_file/ca_list_file(public certificate material) are unaffected. - REST API:
RoutingTable/TrunkGroupCRUD, anApply Changeswrapper, and automatic queue-member cleanup — three follow-ups from reviewing what happens when aSIPUseris deleted:apps/api/views/routing_tables.py/trunk_groups.pyexpose/api/v1/routing-tables/and/api/v1/trunk-groups/(staff-only, like the other SIP resources). Both models now inheritAuditFields(migration0086_routingtable_trunkgroup_audit_fields.py).RoutingTableSerializer.validate_name()pre-checks theDialplanContextname collision thatRoutingTable.save()previously raised as a plain DjangoValidationError— a classapi_exception_handlerdidn’t catch, so it surfaced as a raw500; a new genericDjangoValidationError → 400branch inapps.api.exceptionsnow backstops that class of bug everywhere, not just here. Deleting a routing table still used by aWebhook’s routing-table filter (an M2M with noPROTECT) now returns409instead of silently unlinking it.TrunkGroup.nameis looked up byservices/fastagi/fastagi.pyvia raw SQL from a literaldial-trunk-group,<name>,AGI call embedded in dialplan text — renaming or deleting a group whileTrunkGroup.find_dialplan_references()finds such a reference is now rejected at the model level (clean()/delete(), so it’s enforced for admin and shell too), the admin (TrunkGroupAdmin.has_delete_permission()hides the delete button instead of a 500), and the API (400/409). This is a best-effort text scan — it can’t see a name reached only through an Asterisk variable.apps/api/views/config.pywraps the admin’s “Apply Changes” page:GET /api/v1/config/preview/is the existing dry-run (generated file contents, no writes, no reload) the admin already showed on GET, now available over the API;POST /api/v1/config/apply/writes configs and reloads Asterisk with a requiredmode: "soft"|"hard"field (unlike the admin form, where anything but the literal string"soft"silently means a hard restart). Both require a newIsSuperUserpermission — the existingIsStaffwould have been a privilege escalation for an action that can restart Asterisk and drop every call. A short-lived Redis lock (fails open if Redis itself is down) returns409for a concurrent apply.pbx.admin.ApplyChangesView.apply_changes()/create_configuration_file()now return which files actually changed, surfaced aschanged_filesin the response.core/signals.py(wired fromCoreConfig.ready()) deletes aSIPUser’sQueueMemberrow(s) onpost_delete—QueueMember.interfaceis free text ("PJSIP/{username}"), not a foreign key, so nothing else cleaned it up and a deleted extension stayed inqueues.confas a permanently-unavailable member. Newpython manage.py cleanup_orphan_queue_members --dry-runsweeps rows orphaned before this signal existed. Documented indocs/{en,ua,es}/admin-guide.mdandAPI.md.
- REST API: full CRUD for SIP transports and peers —
apps/api/views/sip_transports.py’sSIPTransportViewSetandapps/api/views/sip_peers.py’sSIPPeerViewSetexposeSIPTransport/SIPPeerunder/api/v1/sip-transports///api/v1/sip-peers/, closing the gap left by the SIP-users CRUD below: aSIPUserrequires atransport, but there was no way to provision one without the admin. SameIsStaff-on-every-method rule (transports return TLS private keys, peers return plaintextsecret/md5_cred). Both models now inheritAuditFields(migration0085_siptransport_sippeer_audit_fields.py) and useAuditAdminMixinin the admin.SIPTransportSerializernormalizes a blanklocal_netstonull(a barelocal_net =line otherwise lands inpjsip.conf) and validates each entry as a CIDR network;SIPPeerSerializerrenames the model’s camelCaseregistrationHere/registrationTheretoregistration_here/registration_thereand requiresregistration_uri/usernamewheneverregistration_thereis set, sincecore.conf.__section_trunk_remote_registration()otherwise skips the section silently. Deleting a transport still referenced by aSIPUser/SIPPeer, or a peer still in aTrunkGroup, now returns409 Conflict(newapps.api.exceptions.Conflict, plus aProtectedErrorbranch inapi_exception_handler) instead of a raw 500 or a silent partial delete. Documented indocs/{en,ua,es}/API.md. - REST API: full CRUD for SIP users —
apps/api/views/sip_users.py’sSIPUserViewSetexposesSIPUser(SIP extensions) under/api/v1/sip-users/(list/create/retrieve/update/partial_update/delete), with?username=,?extension=,?search=filters. Unlike the rest of the API, every method — includingGET— requires a staff or superuser token (newapps/api/permissions.IsStaff), since the resource returns dial-out credentials (secret) plus the derivedrealm/md5_cred(RFC 2617 HA1) that WebRTC endpoints authenticate with regardless of the storedauth_type.SIPUsernow inheritsAuditFields(migration0084_sipuser_audit_fields.py), recordingcreated_by/modified_byon both API and admin writes, matchingBlacklist/Whitelist/Contact/PhoneDevice. The validators shared withSIPUserForm(validate_alphanumeric,min3len; plusvalidate_sip_username, used bySIPPeerForm) moved fromcore/forms.pytocore/validators.pyso the serializer can reuse them without importing Django forms. Writing through this endpoint only updates the database — Asterisk still needs “Apply Changes” in the admin to pick up the change. Documented indocs/{en,ua,es}/API.md. - Daily “longest calls” email report —
python manage.py mail_reportsends a top-N table of the previous day’s longest calls, with a link to each call’s recording, toMAIL_REPORT_RECIPIENTS. Query logic lives inapps/reports/services/longest_calls.py(de-duplicates CDR legs sharing auniqueid, keeping the longest); newEMAIL_*/MAIL_REPORT_*settings inpbx/settings.py; new Ansible cron taskpearlpbx2_mail_report(daily at 06:00, installed only whenpearlpbx2_mail_report_recipientsis set).
Fixed
- Quickstart extension 140 (internal IVR test) didn’t reset
IVR_RETRY— it jumped straight intoivr-main,s,1without clearing the variable first, so a prior failed IVR attempt’s retry count carried over into the test call, unlike thes/i/tentry extensions which reset it.core/management/commands/seed_quickstart.pynow setsIVR_RETRY=0before thegoto. SIPUserAdminchange/add page returned HTTP 500 —readonly_fields = ("realm_display", "md5_cred_display")overrode (instead of extending)AuditAdminMixin’s readonly fields, socreated_at/modified_at— non-editable model fields still referenced by the mixin’s audit fieldset — became “editable” as far as Django’sModelFormwas concerned, raisingFieldErroron every/admin/core/sipuser/<id>/change/request.core/mixins/audit_admin.py’sAuditAdminMixinnow provides aget_readonly_fields()override that unions the audit fields in regardless of what a subclass sets, so a subclass declaring its ownreadonly_fields(asSIPUserAdmin/QueueAdmindo) can no longer drop them by accident.- WebRTC (wss) SIP users were dialed into a context that was never generated —
__make_pjsip_conf_webrtc_user()incore/conf.pyemittedcontext=<username>for every WebRTC endpoint instead of the user’s routing table, but nothing inmake_dialplan_contexts()/make_routing_tables()ever generates a context named after a username — so an inbound call from a WebRTC user landed in a nonexistent context and went nowhere. Now usesuser.routing_table.name, matching how regular (non-WebRTC) endpoints are generated. - AEL dialplan validator didn’t check
goto/Goto()targets — a baregoto +0681231231,1;(leading unquoted+) passed Django’s validation but was rejected by Asterisk’s AEL compiler at reload time with a syntax error.core/validators.pynow validates both the bare-gotoandGoto()call forms. - Stray spaces in generated AEL
globals { }block —make_dialplan_globals()incore/conf.pyemittedname = value;(spaces around=); changed toname=value;to match Asterisk AEL’s expected global-assignment syntax. - Debian installs missing
cron—cronwasn’t in thesystemAnsible role’s package list, so the daily backup/report cron jobs installed by other roles silently had nothing to run them on a fresh Debian host (only Ubuntu, wherecronis typically preinstalled, worked by accident). - Firewall installer’s SSH-port confirmation could hang an unattended install — the Ansible
firewallrole’s SSH-port detection previously stopped with an interactivepause: prompt: "Press Enter to continue", blocking any install run without a TTY (e.g.ansible-pull, CI). It now only logs the detected port viadebugand proceeds. AudioFileByUniqueidViewreturned HTTP 500 for every request — it was missing fromReportViewPermissionMixin.VIEW_PERMISSION_MAPPING(apps/reports/mixins.py), soget_permission_required()always raisedImproperlyConfiguredregardless of the caller’s permissions. Found while wiring recording links into the newmail_reportcommand.- nginx HTTP→HTTPS redirect broke Let’s Encrypt HTTP-01 validation — the generated vhost’s
return 301 https://...sat directly in theserverblock, which nginx evaluates during the server-rewrite phase, before location matching — so it fired for the^~ /.well-known/acme-challenge/location too, and the ACME client’s HTTP-01 request never reached the challenge file.ansible/roles/nginx/templates/pearlpbx2.nginx.j2now wraps the redirect in alocation /block, so the more specific acme-challenge location takes precedence.
Changed
Settings.allow_monitornow defaults toTrue(migration0090_alter_settings_allow_monitor_default_true.py) — global call monitoring is enabled by default on new installs instead of requiring an admin to opt in.asterisk_versionpinned to22.11.0(ansible/group_vars/all.yml) instead of the floating22-current, so a fresh install always builds a known-good version rather than whatever is newest on install day.install.sh’s post-install output trimmed — the optional-integration instructions (MP3 backup sync, Slack server alerts, Slack unmatched-call notifications) and interrupted-install recovery notes moved out of the script’s echoed output and intodocs/{en,es,ua}/quickstart.md, which the script now points to instead of printing everything inline.
[2.7.2] - 2026-08-23
Added
- SIP trunk
[identify]escape hatch:custom_identify_settings—SIPPeergained a free-text field for the body of the generated[identify]section (migration0081_sippeer_custom_identify_settings.py), exposed in the admin’s “Advanced” fieldset next tocustom_aor_settings.core/conf.py’s[identify]generation previously only supported IP-basedmatch, which can’t distinguish multiple lines behind the same source IP — e.g. a GSM gateway where each of 4 ports registers from the same address and inbound calls only carry a distinguishingContact: 0001header. When the field is set,__section_trunk_endpoint()also derivesidentify_byfrom its content instead of the old singleidentify_by=ipcheck:headerif the text containsmatch_header,ipif it containsmatch=, plususernamewheneverregistrationHereis enabled. This isn’t cosmetic — Asterisk’sidentify_bydefault (username,ip) has noheadervalue, and REGISTER-to-AOR resolution requires listing a method Asterisk documents as AOR-capable (onlyusernamequalifies among the ones used here), so a header-matched trunk that also registers needs bothheader(for inbound INVITEs, since From/To carry real phone numbers, not the port id) andusername(for REGISTER, since the gateway’s own username per line does match the AOR name) at once. Trunks that leave the field empty are unaffected —identify_by=ipis still emitted exactly as before. Covered by new tests incore/tests.py. - REST API: pause/unpause and list queue members —
POST /api/v1/queues/members/pause/sends AMIQueuePausefor a giveninterface(optionally scoped to onequeue), andGET /api/v1/queues/members/(optional?queue=<name>) returns live member status (paused, status, calls taken, …) via AMIQueueStatus. Both talk to Asterisk directly, independent of the dashboard’s Redis-backed state, so they work even withoutdashboard-listenerrunning.core/ami.AsteriskManagementInterfacegainedqueue_pause()andqueue_members(); the dashboard’s existingpause_queue_member()view now goes through the same wrapper instead of opening its own AMI connection. New validatorcore.validators.validate_asterisk_interfaceguards theinterfacefield against AMI injection. AMI timeouts (all in seconds) are now named settings instead of scattered magic numbers:ASTERISK_AMI_DEFAULT_TIMEOUT(long-running admin actions like reload/restart),ASTERISK_AMI_QUICK_TIMEOUT(pause/status/hangup-style actions),ASTERISK_AMI_RESPONSE_MARGIN(slack added on top of an Originate’s owntimeout_ms). - Docker integration test suite against a real Asterisk —
tests/integration/test_queue_members.pyseeds a real Queue + PJSIPQueueMember, applies the generated config the same way “Apply Changes” does, reloads a liveandrius/asterisk:22container, and drivesPOST /api/v1/queues/members/pause//GET /api/v1/queues/members/against it — confirming pause state actually changes in Asterisk, not just that the right AMI calls were made. New isolated stackdocker-compose.integration.yml(own volumes/DB/network, no host ports), run viamake integration-test. Opt-in in CI (.github/workflows/integration-test.yml, push to main/master or manual dispatch) rather than on every PR, since it takes tens of seconds versus the existing mocked suite’s seconds. - CRM webhooks: separate outgoing-call event chain — new events
call.outgoing/call.outgoing_answered/call.outgoing_ended, fired for calls placed by a SIP user (never a trunk), independent of the existing inbound chain (call.incoming/call.answered/call.missed/call.ended).Webhookgainedsend_outgoing,send_outgoing_answered,send_outgoing_ended(migration0005_webhook_outgoing_events.py); each requires at least one selected routing table, mirroring how inbound events require a context or queue. New payload fieldsdirection("inbound"/"outbound"),dest_channel,dial_status,answered. Outgoing-call detection is endpoint-based, not context-based: both a SIP user and a trunk can end up with a PJSIP context equal to a routing table’s name, soapps/webhooks/sync.pynow serializes asip_usersmap ({endpoint: routing_table}, SIP users only) intowebhooks:config, andwebhook_sender.extract_endpoint()resolves the AMI channel’s endpoint against it — a trunk’s channel never matches, even sharing a routing table’s name with a SIP user.services/dashboard/dashboard_listener.pywiresAMI DialEnd(DialStatus=ANSWER) tocall.outgoing_answered, closing a gap noted in the previous release (direct/non-queue calls previously had no “answered” signal at all). - CRM webhooks:
linkedid,channel,channel_vars; system-channel filter — every event now carrieslinkedid(Asterisk’s own cross-channel call-correlation id, read from AMILinkedid) andchannel(the channel name), so a CRM can group separate webhook deliveries belonging to the same logical call without guessing fromuniqueidproximity or timestamps. Newchannel_varsobject exposes allow-listed Asterisk channel variables (envWEBHOOK_CHANNEL_VARS, defaultULINE— e.g.services/fastagi’s parking-slot allocator), tracked via the existingVarSethandler and echoed on every event where the variable is already known.call.outgoing(and itsoutgoing_answered/outgoing_endedchain) is now skipped by default for a channel Asterisk created viaDial()/Originate()that has not yet beenGoto()’d to a real extension (extenstill the dialplan placeholder"s") — pure system noise for a CRM; set envWEBHOOK_SEND_SYSTEM_CHANNELS=trueto restore the old behavior. Migration0007_add_correlation_fields_to_templates.pyupgrades unmodified defaultpayload_templaterows to include the three new placeholders, leaving customized templates untouched.
Changed
- BREAKING for existing
Webhookrows that userouting_tables— before this release,routing_tableswere merged into the samecontextslist used forcall.incomingmatching (added in 2.7.1), so a webhook configured with only a routing table firedcall.incoming/call.endedfor outbound calls. Migration0006_migrate_routing_table_webhooks.pytranslates existing rows automatically (send_outgoing = send_incoming,send_outgoing_ended = send_ended; inbound flags are cleared only if the row had nocontexts/queuesof its own), but the CRM-side handler must be updated to expectcall.outgoing*instead ofcall.incoming/call.endedfor those calls. Deploy order:migrate+sync_webhooksfirst, then restartdashboard-listener— an old listener process doesn’t know the newwebhooks:configkeys (sip_users, separaterouting_tables) and won’t fire the outgoing chain during the rollout window. apps/webhooks/models.Webhook.routing_tableshelp text and admin validation updated to reflect that routing tables now filter only the outgoing chain, never the inbound one.
[2.7.1] - 2026-08-11
Added
- CRM webhooks: outbound calls now match via routing table —
Webhookgained arouting_tablesM2M field (migration0004_webhook_routing_tables.py); a SIP user’s PJSIP context is its routing table’s name, so outbound calls were never matched by webhooks that only listed inboundDialplanContexts.apps/webhooks/sync.pymerges routing table names into the samecontextslist sent to the dashboard listener; admin form, m2m signals, and tests updated accordingly. - Public release files —
LICENSE(PolyForm Shield 1.0.0 — permits any use, including commercial, except operating a competing product/service),NOTICE,CONTRIBUTING.md,QUICKSTART.md(Ansible and Docker Compose install paths), SPDX headers (LicenseRef-PolyForm-Shield-1.0.0) incore/models.py,core/conf.py,manage.py,pbx/settings.py. - Full Docker Compose stack — new
services/fastagi/Dockerfile,services/dashboard/Dockerfile,services/callback/Dockerfile;docker-compose.ymlgainedfastagi,dashboard-listener,callback-service(opt-in via--profile callback), andasterisk-init, which seeds a minimal AMI-enabledmanager.confand patchesmodules.confto loadres_crypto.sobefore Asterisk’s first boot — the vendorandrius/asterisk:22image ships both AMI disabled andres_cryptounloaded (the latter breaks the image’s own healthcheck).docker-entrypoint.shrunsmigrate/collectstaticidempotently on everydjangocontainer start.docker-compose.override.ymlgivesdjangoa source bind mount +uvicorn --reloadfor local development, picked up automatically bydocker compose up. PARKING_ULINE_MIN/PARKING_ULINE_MAXexplicitly declared inpbx/settings.py(previously only read viagetattr(..., default)inapps/dashboard/views.py); documented inenv.sample.FASTAGI_HOST/FASTAGI_PORTenv vars forservices/fastagi/fastagi.py— the server previously always bound127.0.0.1:4573, unreachable from a separate Asterisk container; defaults unchanged for existing bare-metal/systemd installs.
Changed
- License: the project is released under PolyForm Shield 1.0.0 (source-available) — permits any use, including your own commercial deployment, except building a competing product or service on the code. See
LICENSE/NOTICE/README## License. - README rewritten: license badge + Quick Start badge,
## Quick Startnow points toQUICKSTART.md,## Licenseand## Contributingsections rewritten for the new license andCONTRIBUTING.md. DJANGO_SECRET_KEYgeneration hint switched from a Django management-command one-liner toopenssl rand -hex 50(env.sample,pbx/settings.py) — the former assumed Django was already installed locally, which isn’t true for the Docker-first setup path..gitignore: added.env,*.env,staticfiles/,*.pyc,.idea/,local_settings.py,tasks/.docs/reorganised:CHANGELOG.mdmoved to the repository root;docs/en/install_asterisk.mdanddocs/en/INSTALL.mdremoved (superseded byansible/install.yml, which already automates everything they described by hand — including the/etc/asteriskownership fix);docs/en/realtime_in_future.mdmoved to the (git-untracked)tasks/directory as an internal roadmap note.tasks/itself removed from git tracking (internal planning docs, not part of the public release) — files kept on disk.
Fixed
- Static files not served under Docker —
DEBUGisFalseeven inDEVMODE=Developmenthere (onlywithout_asterisk_on_localhostflips it), so Django wasn’t serving/static/itself and there is no nginx in front of thedjangocontainer in Docker Compose. Addedwhitenoise+WhiteNoiseMiddleware; no effect on bare-metal/Ansible installs where nginx already serves/static/first. djangoserviceREDIS_URLmisconfigured indocker-compose.yml— it was set via unusedREDIS_HOST/REDIS_PORTvariables (a pattern only the standalone services read); Django itself only reads a singleREDIS_URL, which was silently falling back toredis://localhost:6379inside the container. Fixed toREDIS_URL=redis://redis:6379; this was breaking the/dashboard/ulines/page and would also have broken the WebSocket dashboard (channels_redisuses the same setting).- Broken
docs/openapi.yamllink in README — the file lives atdocs/en/openapi.yaml; README now links to bothdocs/en/API.mdanddocs/en/openapi.yaml.
[2.7.0] - 2026-08-10
Added
- AEL global variables — new
DialplanGlobalVariablemodel (core/models.py, migration0080) lets an admin define namedglobals { }entries via the Django admin, emitted at the top of the generatedextensions.aelbymake_dialplan_globals()incore/conf.py. Name/value are validated with newvalidate_ael_variable_name/validate_ael_variable_valuevalidators incore/validators.py(identifier syntax; no;or line breaks in the value). Covered by new tests incore/tests.py. - Calls by Destination Number report —
AnalyticsDestinationCallsView(apps/reports/views.py), templateanalytics_destination_calls.html, andAnalyticsDestinationCallsForm, registered at/reports/analytics/destination-calls/. Counts answered/total external inbound calls grouped by destination (B-number) — inbound leg must belong to aSIPPeer— with unique-caller counts, average talk time, filters for destination/exclude-contacts/top-N, and CSV export. Gated byview_analytics_reports. Translations updated for en/es/uk. backup_asterisk.sh— dailytar.gzbackup of/etc/asteriskwith configurable retention (RETENTION_DAYS, default 14 days) and a Slack alert on failure. Configured via/etc/PearlPBX/backup_asterisk/env(templated frombackup_asterisk.env.j2in thesystemAnsible role), backing up into/var/backups/asterisk-etc; installed as a daily cron job (02:30) by thepearlpbx2role.
Changed
- English-only API responses — new
core.middleware.ForceEnglishAPIMiddlewareforces theenlocale for any request under/api/, regardless ofAccept-Languageor the caller’s session locale, so external integrations get stable English messages. - Channel-classification helpers extracted — new
apps/reports/services/channels.pymodule (peer_channel_regex()/user_channel_regex()) replaces the inline regex-building previously duplicated inCDRReportView; regexes are now computed lazily and memoized, and an empty SIPPeer/SIPUser list yieldsQ(pk__in=[])instead of a malformed regex. pg_backup_pearlpbx2moved fromcron.dailyto an explicit cron entry — now runs daily at 01:30; the legacy/etc/cron.daily/pg_backup_pearlpbx2script is removed during install.syncmp3.sh—BACKUP_MP3_DAYS=0now disables backup-directory cleanup instead of deleting every file in it.
Fixed
- CRM webhook
call.answered/call.missedmissing destination number —services/dashboard/webhook_sender.py’s_on_agent_connect/_on_abandonnow enrich their payloads withexten/contextfrom thewebhook:notified:{uniqueid}marker (the same data already included incall.incoming/call.ended), so CRM integrations can see which number the caller dialed for answered/missed queue calls too. Covered by new tests inservices/dashboard/tests.py. - Ansible update playbook —
manage.py showmigrations/migrate/collectstaticsteps inansible/update.ymlnow run with--skip-checks, so Django system checks unrelated to the update (e.g. warnings from in-progress model changes) no longer abort the update process. - Env-file parsing in Ansible update/rollback —
ansible/update.ymlandansible/rollback.ymlno longer read/etc/PearlPBX/PearlPBX2/envviaslurp+ base64 decode;update.ymlnow useslookup('file', …)with a regex that correctly handles digits in variable names and strips surrounding quotes from values.
[2.6.0] - 2026-07-24
Added
- Ansible rollback procedure —
rollback.shplusansible/rollback.ymlandbin/pearlpbx2_resolve_rollback_target.pylet an admin roll a deployment back N steps using a deploy-state ledger written byupdate.yml: it resolves the target commit/migration state, reverts Django migrations while the newer migration files are still on disk, checks out and syncs the rolled-back code (aborting if the source repo has uncommitted changes), reinstalls dependencies for the main app and thecallback/dashboard/fastagiservices, and restarts them.ansible/update.ymlnow also ensures the deploy-state directory exists before writing the ledger. sync_manager_usersmanagement command (core/management/commands/sync_manager_users.py) — creates/updates the AMIManagerUsersrecords for the built-incallback,dashboard_listener, andfastagiservices from fixed scopes, mirroringmanager.conf.j2; wired into the Ansiblepearlpbx2role install so these accounts are (re)provisioned automatically instead of requiring manualmanager.confedits. Covered by new tests incore/tests.py.- Token authentication for dashboard WebSocket and read-only JSON API —
apps/dashboard/consumers.py’sAsteriskEventsConsumer.connect()now also accepts a DRF auth token (via?token=query param orAuthorization: Token <key>header) when there is no authenticated session; a matchingtoken_or_login_requireddecorator inapps/dashboard/views.pyextends the same fallback to the read-only endpoints (get_sip_endpoints,get_queue_state,get_all_queues,get_all_channels,get_channel,get_active_calls,get_missed_calls,get_channels_by_type), enabling CRM/external integrations that can’t rely on Django sessions.docs/ua/crm-integrator-guide.mdupdated accordingly.
Changed
ALLOWED_HOSTSduring install now includes the host’s FQDN,localhost, and all detected IPv4 interface addresses (in addition to the short hostname and127.0.0.1), so the admin UI is no longer rejected with a 400 when reached via a secondary network interface’s IP.CHANNEL_LAYERSRedis backend given explicitcapacity(256) andexpiry(60s) settings instead of relying on channels_redis defaults.- Ansible install playbook simplified — removed the interactive timezone-confirmation preflight step and the PostgreSQL system-timezone-sync task (
ansible/install.yml,ansible/roles/postgres/tasks/main.yml); theasteriskrole now pre-seeds/etc/asteriskwith PearlPBX2’s own baseline configs before runningmake basic-pbx, so Asterisk’s demo queues/agents are never installed in their place;collectstaticnow runs with AMI connection settings andCSRF_TRUSTED_ORIGINSin its environment.
Added
- CRM webhooks (
apps/webhooks/) — newWebhookmodel lets an admin register one or more CRM endpoints that receive JSON POST notifications for call events (incoming, answered, ended/missed), driven fromservices/dashboard/dashboard_listener.pyvia the newservices/dashboard/webhook_sender.py. Payloads are built from a configurable, validatedpayload_template(JSON object with${variable}placeholders such ascaller_id_num,queue,recording_url,wait_time, etc.), added in a follow-up migration (0003_alter_webhook_payload_template.py). Async_webhooksmanagement command andapps/webhooks/sync.pykeep webhook definitions in sync. Documented indocs/ua/crm-integration.mdanddocs/ua/crm-integrator-guide.md. - Call recording lookup API —
GET /api/v1/recordings/<uniqueid>/(apps/api/views/recordings.py, backed byapps/reports/services/recordings.py) serves the recorded audio (wav/mp3, Range-request support) for a given Asterisk uniqueid; this is the deterministic URL delivered to CRMs asrecording_urlin webhook payloads. - ConfBridge conference calling — new
POST /api/v1/calls/conference/endpoint (apps/api/views/calls.py,ConferenceSerializer) originates multiple parties (e.g. operator, client, driver) concurrently into a shared ConfBridge room, alongside the existing single-leg/calls/originate/.core/ami.pygained non-blockingsend_originate()split out fromoriginate()so conference legs dial in parallel instead of sequentially.core/conf.py/pbx/admin.pygenerateconfbridge.confdefault profiles; a reserved"conference"DialplanContext/DialplanExtension(migration0079) and acore/checks.pysystem check prevent an admin from creating a colliding context or routing table name.core/validators.pywhitelists ConfBridge in the dialplan AEL validator. - Repository docs reorganised under
docs/en/(English) anddocs/ua/(Ukrainian); newdocs/en/realtime_in_future.md,docs/ua/crm-integration.md,docs/ua/crm-integrator-guide.md.bin/rename_pearlpbx2_services.shadded to rename legacy systemd units to thepearlpbx2-*naming scheme.
Fixed
- PJSIP outbound registration AOR contact —
__build_aor_contact_line()incore/conf.pynow falls back toregistration_uriwhencontact_uriis not set (with a warning, since registrar and media host may differ), instead of silently emitting no contact. For trunks withregistrationThere(registering to a remote provider), the AOR now seeds a static bootstrap contact so calls aren’t blackholed in the window before the first successfulREGISTER; once registration succeeds,remove_existing=yesreplaces it with the learned contact.
[2.4.0] - 2026-07-16
Added
- Slack notifications for missed queue calls — the dashboard listener (
services/dashboard/) can now optionally send an aggregated Slack message when callers abandon a queue. All abandons within a configurable debounce window (default 60 s) are grouped into a single message per queue. Configure viaSLACK_MISSED_CALL_WEBHOOK_URLandMISSED_CALL_DEBOUNCE_SECONDSinservices/dashboard/env. Feature is off by default (empty webhook URL). - Classic AGI scripts —
services/agi/now shipsmissed_call.pyandunmatched_call.pyfor per-call Slack notifications from Asterisk dialplan, plus a sharedagi_common.pylibrary withnotify_slack()helper. Config at/etc/PearlPBX/AGI/env. - REST API migrated to Django REST Framework —
apps/api/endpoints for lists/blacklist/whitelist/contacts are now DRFViewSets registered viaDefaultRouter, with proper serializers (apps/api/serializers.py) replacing hand-rolledJsonResponseviews. New call-control endpoints underapps/api/views/calls.py, a machine-readabledocs/openapi.yamlspec, anddocs/API.mdupdated to match.AGENTS.mdadded documenting the API for agent/automation consumers. - Pause/unpause queue members from the dashboard — the new live dashboard (
apps/dashboard/views.py,new_dashboard.html) can send AMIQueuePause/QueueUnpausefor a given interface directly from the Queues tab; restricted to staff users. - Queue member import management commands —
core/management/commands/add_queue_members.pyandimport_pbx1_users.pyfor bulk-importing queue members and migrating users from a legacy PBX1 install. manage.shhelper script for common day-to-day operational commands, alongside improvements toinstall.sh/update.sh.- Extended SIP Peer (trunk) configuration —
host_portreplaced with three explicit fields:registration_uri,contact_uri, andmatch_hosts(migrations0069–0072), giving independent control over the registration server, AOR contact, andidentifymatch hosts. Addedcontact_userandauth_type(plaintext/MD5) fields (migration0073) for trunks that require a distinct contact user or MD5 authentication. - Admin warning for skipped PJSIP users — the Apply Changes page now lists SIP users excluded from
pjsip.confgeneration (e.g. due to validation failures) so misconfigurations aren’t silently dropped. bin/asterisk_logrotate.sh— daily logrotate script for Asterisk logs, wired into theasteriskAnsible role.system_monitor.shstartup Slack alert — sends a one-time “monitoring started” message to Slack on first run per host.- Ansible: PostgreSQL timezone configuration during install, and a new
manager.conf.j2template plus expandeddocs/admin-guide.md/docs/user-guide.md. - Queue config generation now emits
maxlen,weight,setqueuevar,random-periodic-announce, andforce_longest_waiting_calleroptions inqueues.conf.
Fixed
- CDR report timestamps —
CDRReportViewno longer raises when a stored datetime is naive; it now falls back to formatting it as-is instead of only handling timezone-aware values. - Permission checks in
apps/reports/mixins.pysimplified and corrected — removed ~70 lines of redundant/incorrect logic. queues.confannounce-holdtimenow emitted as its configured numeric value instead of being coerced to a boolean.- PostgreSQL backup script renamed and fixed —
bin/pg_backup_asterisk.sh→bin/pg_backup_pearlpbx2.sh, with corrected environment handling in thesystemAnsible role. wav2mp3_monitor.shand related scripts no longer redirect their own output into the log they monitor;pg_backup/syncmp3/system_monitorscripts had assorted reliability fixes.custom_list_namestable reference typo inservices/fastagi/fastagi.pyfixed (was querying the pluralcustom_lists_names).- Callback scheduling — FastAGI callback insert now uses
make_interval()instead of string-interpolating the delay into theINTERVALliteral. ConfigurationFileAdminno longer silently discards name/description/path edits when file content is unchanged — it now saves the row and informs the admin that no new version was created.DialplanContext/RoutingTableuniqueness — addedclean()validation (in addition to the existingsave()check) so the “name already used in the other model” conflict surfaces as a normal form error.- Phone provisioning config directory auto-created if missing when writing a device config file; removed a dead/no-op
apply_all_configurationsview fromapps/provision/. HomepageStatusViewAMI client is now always logged off viafinally, even when connecting or querying it raises.MusicOnHold.mode/.sortdefaults corrected to use the actual enum members instead of a raw integer (migration0078).
Changed
SIPUser.md5_crednow computes HA1 asMD5(username:realm:password)per RFC 2617 (previously the realm and password were swapped).AllowedHostsIPMixin.get_client_iponly trustsX-Forwarded-Forwhen the direct peer’s address is listed in the newPEARLPBX_API_TRUSTED_PROXIESsetting; otherwise it falls back toREMOTE_ADDR, closing an IP-allowlist bypass via a spoofed header.ApplyChangesViewconfig file paths are now built withos.path.join/os.path.normpathand validated to stay insideASTERISK_ROOT_DIR, rejecting a craftedConfigurationFile.paththat could otherwise escape the sandbox; the AMI connection used to apply changes is now always logged off in afinallyblock.PasswordWithToggleInputwidget escapes field name/value/attrs before interpolating into HTML, and its “generate password” JS now draws fromcrypto.getRandomValuesinstead ofMath.random().SIPUser.secret/ManagerUsers.secretno longer require database-level uniqueness (removedunique=True) — multiple endpoints legitimately sharing a secret is not an error condition.- No committed default secrets —
ASTERISK_MANAGER_SECRETandDJANGO_SECRET_KEYno longer ship with a real fallback value; both are required via environment variables in any network-reachableDEVMODE(Development/Staging/Production) and raiseImproperlyConfiguredif missing, falling back to an obviously-fake value only forwithout_asterisk_on_localhost. - Cookie security flags (
SESSION_COOKIE_SECURE,CSRF_COOKIE_SECURE) are now also enforced in Staging, not just Production. - Default log level lowered from DEBUG to INFO for
core/apps/__main__loggers to avoid leaking AMI event payloads (caller IDs, PII) into journald by default. /moh/<path>static file serving now requires an authenticated session (login_required), since the MOH tree is writable by admins.- Dashboard queue-control endpoints (
hangup_channel,pause_queue_member) now requirerequest.user.is_staff, not just login. merge_args_envin the callback and dashboard services now correctly prioritises CLI arguments over environment defaults (previous logic had the priority inverted).features.confdefault template simplified/updated.- Ansible:
asteriskrole now manageslogger.confdefaults and logrotate;ansible.cfgandupdate.shfixes for the update playbook.
Security
- TLS/AMI/API hardening pass (“fix the critical/high/medium issues” commits) covering: IP-spoofing via
X-Forwarded-For, HA1 credential hash order, committed default secrets for AMI and Django, insecureMath.random()password generation, unescaped HTML in the password widget, a path-traversal vector in Apply Changes’ config writer, missing staff checks on dashboard AMI control endpoints, dialplan validation bypass on programmatic writes (management commands/imports), and open static-file serving of the MOH directory. See details above under Fixed/Changed.
[2.3.3] - 2026-05-24
Security
- TLS private key TOCTOU race fixed —
_write_cert_filenow creates files with0o640permissions atomically viaos.open(), eliminating the window where a private key was world-readable betweenopen()andchmod(). - Path traversal in cert filenames blocked —
SIPTransport.namenow validates against[a-zA-Z_][a-zA-Z0-9_-]*(migration0068);_write_cert_filealso strips path components withos.path.basename()as a second line of defence.
Fixed
- TLS cert writes no longer trigger on config preview —
make_pjsip_conf_transports()is now a pure function (string only); cert files are written by the newwrite_tls_cert_files()called exclusively during Apply Changes (POST), not on the preview GET request. cert_write_dirpath normalisation — replaced raw string concatenation withos.path.normpath()to handle trailing slashes inASTERISK_ROOT_DIRcorrectly.
Added
verify_serverandallow_reloadTLS transport options — new boolean fields onSIPTransport(migration0067) emitted topjsip.conffor TLS transports.- TLS certificate content stored in DB —
cert_file,priv_key_file,ca_list_filecontents are now written toASTERISK_CONFIG_DIR/certificate/on Apply Changes.
[2.3.2] - 2026-04-27
Fixed
- Queues.conf generation - ringinuse and timeoutrestart was generated incorrect.
[2.3.0] — 2026-04-12
Added
- Queue agent info in dashboard call modal — when a bridged call involves a queue agent, the agent name and extension are now shown in the call details modal.
- Dashboard agent panel improvements — queue agents tab enhanced with clearer status indicators and layout tweaks.
- Agent state restoration after Asterisk restart — the dashboard service now automatically re-queues paused agents when Asterisk reconnects, so pause states survive restarts.
- AMI timeout configurable — callback service
--ami_timeoutflag added; default raised to 60 s for more reliable trunk connections. - Queue activity report — exclude contacts filter — the queue analytics report now supports filtering out specific contacts from results.
- Link from missed-calls analytics to queue log — the missed-calls report now includes a direct link to the queue activity log for the relevant queue.
Fixed
- Callback service reliability — extensive rework: removed auto-reconnect loop (service now exits cleanly on AMI disconnect and relies on systemd restart), improved error handling and state machine for outbound callback calls.
- Callback report links — fixed broken links between the callback report view and individual callback records.
- PJSIP
identifysection removed when IP:Port not defined —core/conf.pyno longer emits an emptyidentifyblock for endpoints without a static IP, preventing Asterisk config warnings. - CSS for wide screens (1600 px+) — dashboard layout no longer overflows on large monitors; responsive breakpoints added.
Changed
- Dashboard improvements — new API endpoint
GET /dashboard/api/queues/supplies queue member data; dashboard listener publishes richer queue-member events to Redis; CDR report row link updated for consistency. ManageUsers.write_timeoutmigration — corrected default value in migration0065.
[2.2.0] — 2026-03-29
Added
- New live operator dashboard (
/dashboard/live/) — fully rewritten dark-theme dashboard with five tabs: Overview, Queues, PJSIP, Bridged, Channels. - WebSocket connection status indicator (LED pill showing CONNECTING / LIVE state).
- Real-time summary stats: active channels, bridged pairs, queues, waiting callers.
- Old dashboard preserved and accessible at
/dashboard/old/;/dashboard/now redirects to the new dashboard. GET /dashboard/api/endpoints/— returns lists of internal SIPUser usernames and external SIPPeer names from the database, used by the frontend to classify PJSIP channels.POST /dashboard/api/channels/hangup/— terminates a call via AMI; CSRF-protected, channel name validated against allowlist regex.
Fixed
- CDR report — outgoing direction filter: the query now correctly matches both
channel(user pattern) anddstchannel(peer pattern). Previously onlychannelwas checked, causing outgoing calls to trunk to be missed or incorrectly included. - Audio player now stops automatically when the CDR details modal is closed.
Changed
- CDR table: a “click for details” hint appears on row hover to improve discoverability.
- Home page queue list: rebuilt with CSS Grid (3 columns: name / waiting badge / agents) replacing the previous flex layout.
- Media player progress bar: click target enlarged to 20 px tall while the visible track remains 6 px with a background rail, improving seek accuracy.
[2.1.4] — 2025-04-01
Previous stable release. See git history for details.