PearlPBX2
Changelog

Історія релізів

Синхронізовано напряму з CHANGELOG.md продукту. Записи техничні й ведуться англійською — так само, як у самому репозиторії.

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.md and the SPDX-License-Identifier header in core/conf.py, core/models.py, manage.py, pbx/settings.py now 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 new CLA.md.

Added

  • update-asterisk.sh: in-place Asterisk version upgrade with backup and rollback — until now, bumping asterisk_version in ansible/group_vars/all.yml did nothing on an already-installed host (ansible/roles/asterisk/tasks/main.yml’s build block only ran when /usr/sbin/asterisk was absent), so there was no supported way to move a production host from one Asterisk version to another. The new ansible/roles/asterisk_update role: backs up /etc/asterisk, the binaries/modules/headers, and /var/lib/asterisk to a timestamped directory under /var/backups/asterisk-upgrade/ (ledger at state.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-wait for an immediate stop); runs make install only, so configs/sounds/logs/spool/recordings are never touched; and restarts Asterisk plus the AMI-connected services, verifying asterisk -rx "core show version" reports the target before declaring success. update-asterisk.sh --rollback restores the previous binaries from the backup (--restore-configs/--restore-sounds opt into restoring those too, since configs are DB-generated and a blind restore could discard later admin changes); -l/--list shows the upgrade history; mirrors the existing update.sh/rollback.sh code-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 new roles/asterisk/tasks/{build,permissions}.yml, and the extracted-directory lookup is now the deterministic asterisk-{{ asterisk_version }} path instead of an unsorted glob that could pick the wrong version when two source trees coexisted. Documented in docs/{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 proper subjectAltName (previously CN-only, which modern browsers/clients reject) and capped at 397 days (Apple’s 398-day hard limit, was 3650). certbot is installed into a dedicated pip venv at /opt/certbot since Debian/Ubuntu ship certbot 2.x and IP-address certs require ≥ 5.4. The vhost’s ssl_certificate/ssl_certificate_key always 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.sh gained --domain/--email flags (and PEARLPBX2_DOMAIN/PEARLPBX2_EMAIL env vars), ALLOWED_HOSTS/CSRF_TRUSTED_ORIGINS include 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_quickstart creates a transport-wss SIPTransport and webrtcuser1..webrtcuser10 SIPUsers (each a random 16-char password) on the Outgoing routing table, so a fresh install has working browser-calling test accounts out of the box. Under Docker (PEARLPBX2_DOCKER=1), the seeded users get disallow=all / allow=g722,ulaw,vp9,vp8,h264 custom settings instead of the default Opus-first allow list — the andrius/asterisk:22 image ships the Opus SDP/runtime library but not the codec_opus.so translator, so negotiating Opus there fails every Playback() 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 by core.conf.make_local_users_context() for every active SIP user’s entry in the auto-generated PEARLPBX-Users context is now an editable template (migration 0082_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 one DialplanExtension row per SIPUser as a save()/delete() side effect — a design that silently missed any SIPUser created through a Django migration or bulk bulk_create(), since Django’s historical models used during migrations bypass custom save() logic entirely. Migration 0083_repoint_pearlpbx_local_users.py cleans up the now-unused stored rows. See also the PEARLPBX-Users context protection entry below, which builds on the same make_local_users_context().
  • SIP User admin form shows the realm and MD5 credential (HA1) — SIPUserAdmin gained read-only realm_display/md5_cred_display fields next to secret, 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.
  • fastagi dial-trunk-group AGI: configurable dial timeout and Dial() options — the AGI previously hardcoded Dial(..., "120", "rtT") on every retry attempt; it now accepts two additional positional AGI arguments (dial timeout in seconds, Dial() options string), defaulting to new DIAL_TRUNK_GROUP_TIMEOUT/DIAL_TRUNK_GROUP_OPTIONS env vars (120 / tT) when the dialplan call omits them. Documented in services/fastagi/README.md.
  • make convenience targets for Docker — make docker-up (background, no dev override), make docker-dev (foreground, hot reload), make docker-stop, make docker-clean, plus a make help default target listing all targets.
  • Docker Asterisk container hardening — a real healthcheck (asterisk -rx "core show channels count") now gates the django service’s depends_on, so Django no longer starts against an Asterisk that hasn’t finished booting; docker/asterisk-bootstrap.sh also now loads res_srtp.so (missing from the vendor image’s default modules.conf, and required for WebRTC’s DTLS-SRTP — without it every wss call failed to negotiate media) and downloads the official asterisk-core-sounds-en-gsm package into a new asterisk_sounds named volume, since the vendor image ships /var/lib/asterisk/sounds completely empty (no stock prompts at all, so demo-echotest, agent-loginok, the queue-* prompts, etc. were silent). The named volume also fixes a pre-existing bug where the image’s own VOLUME /var/lib/asterisk/sounds declaration shadowed that subpath of the broader asterisk_lib mount with an anonymous volume, making custom sound files invisible to Asterisk. contrib/configs/http.conf’s bindaddr changed from 127.0.0.1 to 0.0.0.0 (PJSIP’s wss transport rides Asterisk’s built-in HTTP server, which must be reachable from outside the container), and docker-entrypoint.sh now runs seed_quickstart/apply_changes only once, gated on a marker file on the shared asterisk_config volume, mirroring ansible/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.py exposes /api/v1/dialplan-contexts/, /api/v1/dialplan-extensions/, /api/v1/dialplan-macros/ (staff-only). All three models now inherit AuditFields (migration 0089_dialplan_audit_fields.py), which also adds validators=[validate_ael_variable_name] to DialplanMacro.name (it previously accepted any string, including hyphens/spaces that break the generated macro name() { AEL block).
    • DialplanMacro gained the same three-layer dialplan-reference guard as TrunkGroup/Queue: DialplanMacro.find_dialplan_references() scans DialplanExtension.dialplan, other macro bodies, and Settings.local_users_dial_template for a literal &name(); AEL macro call (case-sensitive, since AEL resolves macro calls by exact name — unlike Queue’s case-insensitive strcasecmp() lookup). Renaming or deleting a macro still called that way is rejected at the model level (clean()/delete()), the admin (DialplanMacroAdmin via the existing DialplanGuardedDeleteAdminMixin), and the API (400/409 via the existing DialplanGuardedDestroyMixin). 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-level DEFAULT_LOCAL_USERS_DIAL_TEMPLATE fallback used when no Settings row exists or the field is blank.
    • The auto-generated "PEARLPBX-Users" context is now protected. It’s rendered live from SIPUser data by core.conf.make_local_users_context() and excluded from make_dialplan_contexts() — a DialplanExtension stored inside it would silently never reach extensions.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, and DialplanExtension.clean() now rejects creating an extension inside it (previously only checked by DialplanExtensionSerializer.validate_context() at the API layer, leaving the admin’s DialplanExtensionForm able to save one silently). DialplanContextAdmin.has_delete_permission() hides the delete button.
    • DialplanContext.delete() now also guards Webhook.contexts, a plain M2M with no PROTECT — mirroring the existing RoutingTable.delete() guard for Webhook.routing_tables. Deleting a context still referenced by DialplanExtension.context/RoutingRecord.context (both PROTECT) already returned 409 for free via the existing ProtectedError branch in apps.api.exceptions.
    • DialplanExtensionSerializer.context is required even though nullable in the DB, for the same reason as RoutingRecordSerializer.context — make_dialplan_contexts() only emits extensions grouped under a context. Its dialplan field’s validation (Asterisk AEL syntax, and that any &macro(); call references an existing DialplanMacro) comes for free from the model’s existing validate_dialplan_field; create a macro before an extension that calls it. (context, ext) uniqueness uses an explicit UniqueTogetherValidator rather than DRF’s automatic Meta.constraints detection, since the latter injects default=None for the nullable context field, which conflicts with the serializer’s own required=True override.
    • 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’s PhoneDeviceViewSet exposes /api/v1/phone-devices/. mac_address validation (accepts separators/mixed case, normalizes to XX:XX:XX:XX:XX:XX) moved from PhoneDeviceForm.clean_mac_address() to a reusable core.validators.validate_mac_address(). A new POST /api/v1/phone-devices/<id>/provision/ action wraps PhoneProvisioningManager.provision_device() — the same thing the admin’s “Apply configurations” button does, for one device over the API.
    • apps/api/views/queues.py gained QueueViewSet/QueueMemberViewSet (DB config) alongside the existing AMI-backed QueueMemberPauseView/QueueMemberListView (live runtime state) — the module docstring and each resource’s docs now spell out the difference, since GET /api/v1/queue-members/ and GET /api/v1/queues/members/ both answer “who’s in this queue” from entirely different sources of truth. Queue/QueueMember now inherit AuditFields (migration 0088_queue_queuemember_audit_fields.py); QueueAdmin/QueueMemberAdmin use AuditAdminMixin.
    • Queue.name is looked up by Asterisk’s app_queue from a literal Queue(<name>,...) AEL app-call embedded in dialplan text — renaming or deleting a queue while Queue.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), mirroring TrunkGroup’s existing guard. Unlike that guard, the match is case-insensitive (re.IGNORECASE) — app_queue resolves queue names with strcasecmp(), so a differently-cased dialplan call really does reach the queue.
    • QueueSerializer.strategy is 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 emit strategy=None into queues.conf. QueueMemberSerializer.interface reuses validate_asterisk_interface (previously only applied to the AMI pause endpoint, never to the model itself); member_name defaults to "" instead of allowing null, since a null value renders as the literal word None in the generated member => ... line. A given (queue, interface) pair must be unique (UniqueTogetherValidator, 400 on conflict).
    • Documented in docs/{en,ua,es}/API.md.
  • REST API: CRUD for RoutingRecord — apps/api/views/routing_records.py’s RoutingRecordViewSet exposes prefix-based routing rules under /api/v1/routing-records/ (staff-only, like the other resources in this API). RoutingRecord now inherits AuditFields (migration 0087_routingrecord_audit_fields.py). context/routing_table are required at the API layer even though nullable in the DB — core.conf.make_routing_tables() interpolates context with no null guard (a null value would literally emit goto None,${EXTEN},1; into the generated dialplan), and a record with no routing_table never appears in any table’s generated block. ?name=, ?prefix=, ?routing_table=, ?context=, ?search= filters. Documented in docs/{en,ua,es}/API.md.
  • REST API: secret/md5_cred/priv_key_file are hidden in list responses — SIPUserSerializer/SIPPeerSerializer (secret, md5_cred) and SIPTransportSerializer (priv_key_file) now return null for these fields on GET /api/v1/sip-users/, /sip-peers/, /sip-transports/ (the paginated list), via a new HideSensitiveInListMixin keyed off view.action == "list". The real value is still returned by GET .../<id>/ and by the object POST/PATCH return — this only keeps credentials out of a bulk dump. cert_file/ca_list_file (public certificate material) are unaffected.
  • REST API: RoutingTable/TrunkGroup CRUD, an Apply Changes wrapper, and automatic queue-member cleanup — three follow-ups from reviewing what happens when a SIPUser is deleted:
    • apps/api/views/routing_tables.py/trunk_groups.py expose /api/v1/routing-tables/ and /api/v1/trunk-groups/ (staff-only, like the other SIP resources). Both models now inherit AuditFields (migration 0086_routingtable_trunkgroup_audit_fields.py). RoutingTableSerializer.validate_name() pre-checks the DialplanContext name collision that RoutingTable.save() previously raised as a plain Django ValidationError — a class api_exception_handler didn’t catch, so it surfaced as a raw 500; a new generic DjangoValidationError → 400 branch in apps.api.exceptions now backstops that class of bug everywhere, not just here. Deleting a routing table still used by a Webhook’s routing-table filter (an M2M with no PROTECT) now returns 409 instead of silently unlinking it.
    • TrunkGroup.name is looked up by services/fastagi/fastagi.py via raw SQL from a literal dial-trunk-group,<name>, AGI call embedded in dialplan text — renaming or deleting a group while TrunkGroup.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.py wraps 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 required mode: "soft"|"hard" field (unlike the admin form, where anything but the literal string "soft" silently means a hard restart). Both require a new IsSuperUser permission — the existing IsStaff would 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) returns 409 for a concurrent apply. pbx.admin.ApplyChangesView.apply_changes()/create_configuration_file() now return which files actually changed, surfaced as changed_files in the response.
    • core/signals.py (wired from CoreConfig.ready()) deletes a SIPUser’s QueueMember row(s) on post_delete — QueueMember.interface is free text ("PJSIP/{username}"), not a foreign key, so nothing else cleaned it up and a deleted extension stayed in queues.conf as a permanently-unavailable member. New python manage.py cleanup_orphan_queue_members --dry-run sweeps rows orphaned before this signal existed. Documented in docs/{en,ua,es}/admin-guide.md and API.md.
  • REST API: full CRUD for SIP transports and peers — apps/api/views/sip_transports.py’s SIPTransportViewSet and apps/api/views/sip_peers.py’s SIPPeerViewSet expose SIPTransport/SIPPeer under /api/v1/sip-transports///api/v1/sip-peers/, closing the gap left by the SIP-users CRUD below: a SIPUser requires a transport, but there was no way to provision one without the admin. Same IsStaff-on-every-method rule (transports return TLS private keys, peers return plaintext secret/md5_cred). Both models now inherit AuditFields (migration 0085_siptransport_sippeer_audit_fields.py) and use AuditAdminMixin in the admin. SIPTransportSerializer normalizes a blank local_nets to null (a bare local_net = line otherwise lands in pjsip.conf) and validates each entry as a CIDR network; SIPPeerSerializer renames the model’s camelCase registrationHere/registrationThere to registration_here/registration_there and requires registration_uri/username whenever registration_there is set, since core.conf.__section_trunk_remote_registration() otherwise skips the section silently. Deleting a transport still referenced by a SIPUser/SIPPeer, or a peer still in a TrunkGroup, now returns 409 Conflict (new apps.api.exceptions.Conflict, plus a ProtectedError branch in api_exception_handler) instead of a raw 500 or a silent partial delete. Documented in docs/{en,ua,es}/API.md.
  • REST API: full CRUD for SIP users — apps/api/views/sip_users.py’s SIPUserViewSet exposes SIPUser (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 — including GET — requires a staff or superuser token (new apps/api/permissions.IsStaff), since the resource returns dial-out credentials (secret) plus the derived realm/md5_cred (RFC 2617 HA1) that WebRTC endpoints authenticate with regardless of the stored auth_type. SIPUser now inherits AuditFields (migration 0084_sipuser_audit_fields.py), recording created_by/modified_by on both API and admin writes, matching Blacklist/Whitelist/Contact/PhoneDevice. The validators shared with SIPUserForm (validate_alphanumeric, min3len; plus validate_sip_username, used by SIPPeerForm) moved from core/forms.py to core/validators.py so 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 in docs/{en,ua,es}/API.md.
  • Daily “longest calls” email report — python manage.py mail_report sends a top-N table of the previous day’s longest calls, with a link to each call’s recording, to MAIL_REPORT_RECIPIENTS. Query logic lives in apps/reports/services/longest_calls.py (de-duplicates CDR legs sharing a uniqueid, keeping the longest); new EMAIL_*/MAIL_REPORT_* settings in pbx/settings.py; new Ansible cron task pearlpbx2_mail_report (daily at 06:00, installed only when pearlpbx2_mail_report_recipients is set).

Fixed

  • Quickstart extension 140 (internal IVR test) didn’t reset IVR_RETRY — it jumped straight into ivr-main,s,1 without clearing the variable first, so a prior failed IVR attempt’s retry count carried over into the test call, unlike the s/i/t entry extensions which reset it. core/management/commands/seed_quickstart.py now sets IVR_RETRY=0 before the goto.
  • SIPUserAdmin change/add page returned HTTP 500 — readonly_fields = ("realm_display", "md5_cred_display") overrode (instead of extending) AuditAdminMixin’s readonly fields, so created_at/modified_at — non-editable model fields still referenced by the mixin’s audit fieldset — became “editable” as far as Django’s ModelForm was concerned, raising FieldError on every /admin/core/sipuser/<id>/change/ request. core/mixins/audit_admin.py’s AuditAdminMixin now provides a get_readonly_fields() override that unions the audit fields in regardless of what a subclass sets, so a subclass declaring its own readonly_fields (as SIPUserAdmin/QueueAdmin do) 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() in core/conf.py emitted context=<username> for every WebRTC endpoint instead of the user’s routing table, but nothing in make_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 uses user.routing_table.name, matching how regular (non-WebRTC) endpoints are generated.
  • AEL dialplan validator didn’t check goto/Goto() targets — a bare goto +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.py now validates both the bare-goto and Goto() call forms.
  • Stray spaces in generated AEL globals { } block — make_dialplan_globals() in core/conf.py emitted name = value; (spaces around =); changed to name=value; to match Asterisk AEL’s expected global-assignment syntax.
  • Debian installs missing cron — cron wasn’t in the system Ansible 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, where cron is typically preinstalled, worked by accident).
  • Firewall installer’s SSH-port confirmation could hang an unattended install — the Ansible firewall role’s SSH-port detection previously stopped with an interactive pause: prompt: "Press Enter to continue", blocking any install run without a TTY (e.g. ansible-pull, CI). It now only logs the detected port via debug and proceeds.
  • AudioFileByUniqueidView returned HTTP 500 for every request — it was missing from ReportViewPermissionMixin.VIEW_PERMISSION_MAPPING (apps/reports/mixins.py), so get_permission_required() always raised ImproperlyConfigured regardless of the caller’s permissions. Found while wiring recording links into the new mail_report command.
  • nginx HTTP→HTTPS redirect broke Let’s Encrypt HTTP-01 validation — the generated vhost’s return 301 https://... sat directly in the server block, 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.j2 now wraps the redirect in a location / block, so the more specific acme-challenge location takes precedence.

Changed

  • Settings.allow_monitor now defaults to True (migration 0090_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_version pinned to 22.11.0 (ansible/group_vars/all.yml) instead of the floating 22-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 into docs/{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 — SIPPeer gained a free-text field for the body of the generated [identify] section (migration 0081_sippeer_custom_identify_settings.py), exposed in the admin’s “Advanced” fieldset next to custom_aor_settings. core/conf.py’s [identify] generation previously only supported IP-based match, 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 distinguishing Contact: 0001 header. When the field is set, __section_trunk_endpoint() also derives identify_by from its content instead of the old single identify_by=ip check: header if the text contains match_header, ip if it contains match=, plus username whenever registrationHere is enabled. This isn’t cosmetic — Asterisk’s identify_by default (username,ip) has no header value, and REGISTER-to-AOR resolution requires listing a method Asterisk documents as AOR-capable (only username qualifies among the ones used here), so a header-matched trunk that also registers needs both header (for inbound INVITEs, since From/To carry real phone numbers, not the port id) and username (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=ip is still emitted exactly as before. Covered by new tests in core/tests.py.
  • REST API: pause/unpause and list queue members — POST /api/v1/queues/members/pause/ sends AMI QueuePause for a given interface (optionally scoped to one queue), and GET /api/v1/queues/members/ (optional ?queue=<name>) returns live member status (paused, status, calls taken, …) via AMI QueueStatus. Both talk to Asterisk directly, independent of the dashboard’s Redis-backed state, so they work even without dashboard-listener running. core/ami.AsteriskManagementInterface gained queue_pause() and queue_members(); the dashboard’s existing pause_queue_member() view now goes through the same wrapper instead of opening its own AMI connection. New validator core.validators.validate_asterisk_interface guards the interface field 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 own timeout_ms).
  • Docker integration test suite against a real Asterisk — tests/integration/test_queue_members.py seeds a real Queue + PJSIP QueueMember, applies the generated config the same way “Apply Changes” does, reloads a live andrius/asterisk:22 container, and drives POST /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 stack docker-compose.integration.yml (own volumes/DB/network, no host ports), run via make 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). Webhook gained send_outgoing, send_outgoing_answered, send_outgoing_ended (migration 0005_webhook_outgoing_events.py); each requires at least one selected routing table, mirroring how inbound events require a context or queue. New payload fields direction ("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, so apps/webhooks/sync.py now serializes a sip_users map ({endpoint: routing_table}, SIP users only) into webhooks:config, and webhook_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.py wires AMI DialEnd (DialStatus=ANSWER) to call.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 carries linkedid (Asterisk’s own cross-channel call-correlation id, read from AMI Linkedid) and channel (the channel name), so a CRM can group separate webhook deliveries belonging to the same logical call without guessing from uniqueid proximity or timestamps. New channel_vars object exposes allow-listed Asterisk channel variables (env WEBHOOK_CHANNEL_VARS, default ULINE — e.g. services/fastagi’s parking-slot allocator), tracked via the existing VarSet handler and echoed on every event where the variable is already known. call.outgoing (and its outgoing_answered/outgoing_ended chain) is now skipped by default for a channel Asterisk created via Dial()/Originate() that has not yet been Goto()’d to a real extension (exten still the dialplan placeholder "s") — pure system noise for a CRM; set env WEBHOOK_SEND_SYSTEM_CHANNELS=true to restore the old behavior. Migration 0007_add_correlation_fields_to_templates.py upgrades unmodified default payload_template rows to include the three new placeholders, leaving customized templates untouched.

Changed

  • BREAKING for existing Webhook rows that use routing_tables — before this release, routing_tables were merged into the same contexts list used for call.incoming matching (added in 2.7.1), so a webhook configured with only a routing table fired call.incoming/call.ended for outbound calls. Migration 0006_migrate_routing_table_webhooks.py translates existing rows automatically (send_outgoing = send_incoming, send_outgoing_ended = send_ended; inbound flags are cleared only if the row had no contexts/queues of its own), but the CRM-side handler must be updated to expect call.outgoing* instead of call.incoming/call.ended for those calls. Deploy order: migrate + sync_webhooks first, then restart dashboard-listener — an old listener process doesn’t know the new webhooks:config keys (sip_users, separate routing_tables) and won’t fire the outgoing chain during the rollout window.
  • apps/webhooks/models.Webhook.routing_tables help 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 — Webhook gained a routing_tables M2M field (migration 0004_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 inbound DialplanContexts. apps/webhooks/sync.py merges routing table names into the same contexts list 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) in core/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.yml gained fastagi, dashboard-listener, callback-service (opt-in via --profile callback), and asterisk-init, which seeds a minimal AMI-enabled manager.conf and patches modules.conf to load res_crypto.so before Asterisk’s first boot — the vendor andrius/asterisk:22 image ships both AMI disabled and res_crypto unloaded (the latter breaks the image’s own healthcheck). docker-entrypoint.sh runs migrate/collectstatic idempotently on every django container start. docker-compose.override.yml gives django a source bind mount + uvicorn --reload for local development, picked up automatically by docker compose up.
  • PARKING_ULINE_MIN/PARKING_ULINE_MAX explicitly declared in pbx/settings.py (previously only read via getattr(..., default) in apps/dashboard/views.py); documented in env.sample.
  • FASTAGI_HOST/FASTAGI_PORT env vars for services/fastagi/fastagi.py — the server previously always bound 127.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 Start now points to QUICKSTART.md, ## License and ## Contributing sections rewritten for the new license and CONTRIBUTING.md.
  • DJANGO_SECRET_KEY generation hint switched from a Django management-command one-liner to openssl 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.md moved to the repository root; docs/en/install_asterisk.md and docs/en/INSTALL.md removed (superseded by ansible/install.yml, which already automates everything they described by hand — including the /etc/asterisk ownership fix); docs/en/realtime_in_future.md moved 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 — DEBUG is False even in DEVMODE=Development here (only without_asterisk_on_localhost flips it), so Django wasn’t serving /static/ itself and there is no nginx in front of the django container in Docker Compose. Added whitenoise + WhiteNoiseMiddleware; no effect on bare-metal/Ansible installs where nginx already serves /static/ first.
  • django service REDIS_URL misconfigured in docker-compose.yml — it was set via unused REDIS_HOST/REDIS_PORT variables (a pattern only the standalone services read); Django itself only reads a single REDIS_URL, which was silently falling back to redis://localhost:6379 inside the container. Fixed to REDIS_URL=redis://redis:6379; this was breaking the /dashboard/ulines/ page and would also have broken the WebSocket dashboard (channels_redis uses the same setting).
  • Broken docs/openapi.yaml link in README — the file lives at docs/en/openapi.yaml; README now links to both docs/en/API.md and docs/en/openapi.yaml.

[2.7.0] - 2026-08-10

Added

  • AEL global variables — new DialplanGlobalVariable model (core/models.py, migration 0080) lets an admin define named globals { } entries via the Django admin, emitted at the top of the generated extensions.ael by make_dialplan_globals() in core/conf.py. Name/value are validated with new validate_ael_variable_name/validate_ael_variable_value validators in core/validators.py (identifier syntax; no ; or line breaks in the value). Covered by new tests in core/tests.py.
  • Calls by Destination Number report — AnalyticsDestinationCallsView (apps/reports/views.py), template analytics_destination_calls.html, and AnalyticsDestinationCallsForm, registered at /reports/analytics/destination-calls/. Counts answered/total external inbound calls grouped by destination (B-number) — inbound leg must belong to a SIPPeer — with unique-caller counts, average talk time, filters for destination/exclude-contacts/top-N, and CSV export. Gated by view_analytics_reports. Translations updated for en/es/uk.
  • backup_asterisk.sh — daily tar.gz backup of /etc/asterisk with configurable retention (RETENTION_DAYS, default 14 days) and a Slack alert on failure. Configured via /etc/PearlPBX/backup_asterisk/env (templated from backup_asterisk.env.j2 in the system Ansible role), backing up into /var/backups/asterisk-etc; installed as a daily cron job (02:30) by the pearlpbx2 role.

Changed

  • English-only API responses — new core.middleware.ForceEnglishAPIMiddleware forces the en locale for any request under /api/, regardless of Accept-Language or the caller’s session locale, so external integrations get stable English messages.
  • Channel-classification helpers extracted — new apps/reports/services/channels.py module (peer_channel_regex()/user_channel_regex()) replaces the inline regex-building previously duplicated in CDRReportView; regexes are now computed lazily and memoized, and an empty SIPPeer/SIPUser list yields Q(pk__in=[]) instead of a malformed regex.
  • pg_backup_pearlpbx2 moved from cron.daily to an explicit cron entry — now runs daily at 01:30; the legacy /etc/cron.daily/pg_backup_pearlpbx2 script is removed during install.
  • syncmp3.sh — BACKUP_MP3_DAYS=0 now disables backup-directory cleanup instead of deleting every file in it.

Fixed

  • CRM webhook call.answered/call.missed missing destination number — services/dashboard/webhook_sender.py’s _on_agent_connect/_on_abandon now enrich their payloads with exten/context from the webhook:notified:{uniqueid} marker (the same data already included in call.incoming/call.ended), so CRM integrations can see which number the caller dialed for answered/missed queue calls too. Covered by new tests in services/dashboard/tests.py.
  • Ansible update playbook — manage.py showmigrations/migrate/collectstatic steps in ansible/update.yml now 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.yml and ansible/rollback.yml no longer read /etc/PearlPBX/PearlPBX2/env via slurp + base64 decode; update.yml now uses lookup('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.sh plus ansible/rollback.yml and bin/pearlpbx2_resolve_rollback_target.py let an admin roll a deployment back N steps using a deploy-state ledger written by update.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 the callback/dashboard/fastagi services, and restarts them. ansible/update.yml now also ensures the deploy-state directory exists before writing the ledger.
  • sync_manager_users management command (core/management/commands/sync_manager_users.py) — creates/updates the AMI ManagerUsers records for the built-in callback, dashboard_listener, and fastagi services from fixed scopes, mirroring manager.conf.j2; wired into the Ansible pearlpbx2 role install so these accounts are (re)provisioned automatically instead of requiring manual manager.conf edits. Covered by new tests in core/tests.py.
  • Token authentication for dashboard WebSocket and read-only JSON API — apps/dashboard/consumers.py’s AsteriskEventsConsumer.connect() now also accepts a DRF auth token (via ?token= query param or Authorization: Token <key> header) when there is no authenticated session; a matching token_or_login_required decorator in apps/dashboard/views.py extends 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.md updated accordingly.

Changed

  • ALLOWED_HOSTS during install now includes the host’s FQDN, localhost, and all detected IPv4 interface addresses (in addition to the short hostname and 127.0.0.1), so the admin UI is no longer rejected with a 400 when reached via a secondary network interface’s IP.
  • CHANNEL_LAYERS Redis backend given explicit capacity (256) and expiry (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); the asterisk role now pre-seeds /etc/asterisk with PearlPBX2’s own baseline configs before running make basic-pbx, so Asterisk’s demo queues/agents are never installed in their place; collectstatic now runs with AMI connection settings and CSRF_TRUSTED_ORIGINS in its environment.

Added

  • CRM webhooks (apps/webhooks/) — new Webhook model lets an admin register one or more CRM endpoints that receive JSON POST notifications for call events (incoming, answered, ended/missed), driven from services/dashboard/dashboard_listener.py via the new services/dashboard/webhook_sender.py. Payloads are built from a configurable, validated payload_template (JSON object with ${variable} placeholders such as caller_id_num, queue, recording_url, wait_time, etc.), added in a follow-up migration (0003_alter_webhook_payload_template.py). A sync_webhooks management command and apps/webhooks/sync.py keep webhook definitions in sync. Documented in docs/ua/crm-integration.md and docs/ua/crm-integrator-guide.md.
  • Call recording lookup API — GET /api/v1/recordings/<uniqueid>/ (apps/api/views/recordings.py, backed by apps/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 as recording_url in 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.py gained non-blocking send_originate() split out from originate() so conference legs dial in parallel instead of sequentially. core/conf.py / pbx/admin.py generate confbridge.conf default profiles; a reserved "conference" DialplanContext/DialplanExtension (migration 0079) and a core/checks.py system check prevent an admin from creating a colliding context or routing table name. core/validators.py whitelists ConfBridge in the dialplan AEL validator.
  • Repository docs reorganised under docs/en/ (English) and docs/ua/ (Ukrainian); new docs/en/realtime_in_future.md, docs/ua/crm-integration.md, docs/ua/crm-integrator-guide.md. bin/rename_pearlpbx2_services.sh added to rename legacy systemd units to the pearlpbx2-* naming scheme.

Fixed

  • PJSIP outbound registration AOR contact — __build_aor_contact_line() in core/conf.py now falls back to registration_uri when contact_uri is not set (with a warning, since registrar and media host may differ), instead of silently emitting no contact. For trunks with registrationThere (registering to a remote provider), the AOR now seeds a static bootstrap contact so calls aren’t blackholed in the window before the first successful REGISTER; once registration succeeds, remove_existing=yes replaces 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 via SLACK_MISSED_CALL_WEBHOOK_URL and MISSED_CALL_DEBOUNCE_SECONDS in services/dashboard/env. Feature is off by default (empty webhook URL).
  • Classic AGI scripts — services/agi/ now ships missed_call.py and unmatched_call.py for per-call Slack notifications from Asterisk dialplan, plus a shared agi_common.py library with notify_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 DRF ViewSets registered via DefaultRouter, with proper serializers (apps/api/serializers.py) replacing hand-rolled JsonResponse views. New call-control endpoints under apps/api/views/calls.py, a machine-readable docs/openapi.yaml spec, and docs/API.md updated to match. AGENTS.md added 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 AMI QueuePause/QueueUnpause for a given interface directly from the Queues tab; restricted to staff users.
  • Queue member import management commands — core/management/commands/add_queue_members.py and import_pbx1_users.py for bulk-importing queue members and migrating users from a legacy PBX1 install.
  • manage.sh helper script for common day-to-day operational commands, alongside improvements to install.sh / update.sh.
  • Extended SIP Peer (trunk) configuration — host_port replaced with three explicit fields: registration_uri, contact_uri, and match_hosts (migrations 0069–0072), giving independent control over the registration server, AOR contact, and identify match hosts. Added contact_user and auth_type (plaintext/MD5) fields (migration 0073) 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.conf generation (e.g. due to validation failures) so misconfigurations aren’t silently dropped.
  • bin/asterisk_logrotate.sh — daily logrotate script for Asterisk logs, wired into the asterisk Ansible role.
  • system_monitor.sh startup 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.j2 template plus expanded docs/admin-guide.md / docs/user-guide.md.
  • Queue config generation now emits maxlen, weight, setqueuevar, random-periodic-announce, and force_longest_waiting_caller options in queues.conf.

Fixed

  • CDR report timestamps — CDRReportView no 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.py simplified and corrected — removed ~70 lines of redundant/incorrect logic.
  • queues.conf announce-holdtime now 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 the system Ansible role.
  • wav2mp3_monitor.sh and related scripts no longer redirect their own output into the log they monitor; pg_backup/syncmp3/system_monitor scripts had assorted reliability fixes.
  • custom_list_names table reference typo in services/fastagi/fastagi.py fixed (was querying the plural custom_lists_names).
  • Callback scheduling — FastAGI callback insert now uses make_interval() instead of string-interpolating the delay into the INTERVAL literal.
  • ConfigurationFileAdmin no 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 / RoutingTable uniqueness — added clean() validation (in addition to the existing save() 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_configurations view from apps/provision/.
  • HomepageStatusView AMI client is now always logged off via finally, even when connecting or querying it raises.
  • MusicOnHold.mode / .sort defaults corrected to use the actual enum members instead of a raw integer (migration 0078).

Changed

  • SIPUser.md5_cred now computes HA1 as MD5(username:realm:password) per RFC 2617 (previously the realm and password were swapped).
  • AllowedHostsIPMixin.get_client_ip only trusts X-Forwarded-For when the direct peer’s address is listed in the new PEARLPBX_API_TRUSTED_PROXIES setting; otherwise it falls back to REMOTE_ADDR, closing an IP-allowlist bypass via a spoofed header.
  • ApplyChangesView config file paths are now built with os.path.join/os.path.normpath and validated to stay inside ASTERISK_ROOT_DIR, rejecting a crafted ConfigurationFile.path that could otherwise escape the sandbox; the AMI connection used to apply changes is now always logged off in a finally block.
  • PasswordWithToggleInput widget escapes field name/value/attrs before interpolating into HTML, and its “generate password” JS now draws from crypto.getRandomValues instead of Math.random().
  • SIPUser.secret / ManagerUsers.secret no longer require database-level uniqueness (removed unique=True) — multiple endpoints legitimately sharing a secret is not an error condition.
  • No committed default secrets — ASTERISK_MANAGER_SECRET and DJANGO_SECRET_KEY no longer ship with a real fallback value; both are required via environment variables in any network-reachable DEVMODE (Development/Staging/Production) and raise ImproperlyConfigured if missing, falling back to an obviously-fake value only for without_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 require request.user.is_staff, not just login.
  • merge_args_env in the callback and dashboard services now correctly prioritises CLI arguments over environment defaults (previous logic had the priority inverted).
  • features.conf default template simplified/updated.
  • Ansible: asterisk role now manages logger.conf defaults and logrotate; ansible.cfg and update.sh fixes 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, insecure Math.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_file now creates files with 0o640 permissions atomically via os.open(), eliminating the window where a private key was world-readable between open() and chmod().
  • Path traversal in cert filenames blocked — SIPTransport.name now validates against [a-zA-Z_][a-zA-Z0-9_-]* (migration 0068); _write_cert_file also strips path components with os.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 new write_tls_cert_files() called exclusively during Apply Changes (POST), not on the preview GET request.
  • cert_write_dir path normalisation — replaced raw string concatenation with os.path.normpath() to handle trailing slashes in ASTERISK_ROOT_DIR correctly.

Added

  • verify_server and allow_reload TLS transport options — new boolean fields on SIPTransport (migration 0067) emitted to pjsip.conf for TLS transports.
  • TLS certificate content stored in DB — cert_file, priv_key_file, ca_list_file contents are now written to ASTERISK_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_timeout flag 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 identify section removed when IP:Port not defined — core/conf.py no longer emits an empty identify block 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_timeout migration — corrected default value in migration 0065.

[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) and dstchannel (peer pattern). Previously only channel was 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.