diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index ce89d418..f6ca9b1f 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -1,6 +1,6 @@ # SecuBox UI & Theme History -_Last updated: 2026-02-17_ +_Last updated: 2026-02-19_ 1. **Unified Dashboard Refresh (2025-12-20)** - Dashboard received the "sh-page-header" layout, hero stats, and SecuNav top tabs. @@ -2334,3 +2334,76 @@ git checkout HEAD -- index.html - Fixed SSL certificate generation (openssl instead of prosodyctl) - Added xchat.gk2.secubox.in route to mitmproxy-in haproxy-routes.json - Fixed route IP from 127.0.0.1 to 192.168.255.1 for container accessibility + +### 2026-02-19: VoIP + Jabber Integration (Asterisk PBX) + +**New Packages:** +- `secubox-app-voip` - LXC-based Asterisk PBX server +- `luci-app-voip` - LuCI dashboard for VoIP management + +**Features:** +- Debian 12 (Bookworm) LXC container with Asterisk PBX +- OVH Telephony API integration for SIP trunk auto-provisioning +- SIP extension management with PJSIP +- Asterisk ARI/AMI support for call control +- Click-to-call web interface +- HAProxy integration with WebRTC support +- Procd service management + +**CLI Commands (voipctl):** +- `install/uninstall` - Container lifecycle +- `start/stop/restart/status` - Service control +- `ext add/del/passwd/list` - Extension management +- `trunk add ovh/manual` - SIP trunk configuration +- `trunk test/status` - Trunk connectivity testing +- `call/hangup/calls` - Call origination and control +- `vm list/play/delete` - Voicemail management +- `configure-haproxy` - WebRTC proxy setup +- `emancipate ` - Public exposure + +**OVH Telephony Integration (ovh-telephony.sh):** +- API signature generation (HMAC-SHA1) +- Billing accounts and SIP lines discovery +- SIP credentials retrieval and password reset +- SMS sending via OVH SMS API +- Auto-provisioning flow for trunk configuration + +**LuCI Dashboard (luci-app-voip):** +- Overview with container/Asterisk/trunk status +- Extensions management (add/delete) +- Trunks configuration (OVH auto-provision, manual) +- Click-to-call dialer with extension selector +- Active calls display with live polling +- Quick dial buttons for extensions +- Logs viewer + +**Jabber VoIP Integration (Phase 3):** +- Jingle VoIP support via mod_external_services +- STUN/TURN server configuration +- SMS relay via OVH (messages to sms@domain) +- Voicemail notifications via Asterisk AMI → XMPP +- New jabberctl commands: jingle enable/disable/status, sms config/send, voicemail-notify +- New RPCD methods: jingle_status/enable/disable, sms_status/config/send, voicemail_status/config +- Updated UCI config with jingle, sms, and voicemail sections + +**Files Created:** +- `package/secubox/secubox-app-voip/Makefile` +- `package/secubox/secubox-app-voip/files/etc/config/voip` +- `package/secubox/secubox-app-voip/files/etc/init.d/voip` +- `package/secubox/secubox-app-voip/files/usr/sbin/voipctl` +- `package/secubox/secubox-app-voip/files/usr/lib/secubox/voip/ovh-telephony.sh` +- `package/secubox/luci-app-voip/Makefile` +- `package/secubox/luci-app-voip/root/usr/libexec/rpcd/luci.voip` +- `package/secubox/luci-app-voip/root/usr/share/luci/menu.d/luci-app-voip.json` +- `package/secubox/luci-app-voip/root/usr/share/rpcd/acl.d/luci-app-voip.json` +- `package/secubox/luci-app-voip/htdocs/.../voip/api.js` +- `package/secubox/luci-app-voip/htdocs/.../view/voip/overview.js` +- `package/secubox/luci-app-voip/htdocs/.../view/voip/extensions.js` +- `package/secubox/luci-app-voip/htdocs/.../view/voip/trunks.js` +- `package/secubox/luci-app-voip/htdocs/.../view/voip/click-to-call.js` + +**Files Modified:** +- `package/secubox/secubox-app-jabber/files/usr/sbin/jabberctl` (added VoIP integration) +- `package/secubox/secubox-app-jabber/files/etc/config/jabber` (jingle/sms/voicemail sections) +- `package/secubox/luci-app-jabber/root/usr/libexec/rpcd/luci.jabber` (VoIP methods) +- `package/secubox/luci-app-jabber/root/usr/share/rpcd/acl.d/luci-app-jabber.json` (VoIP ACL) diff --git a/.claude/WIP.md b/.claude/WIP.md index 1679c681..aad10f2f 100644 --- a/.claude/WIP.md +++ b/.claude/WIP.md @@ -1,6 +1,6 @@ # Work In Progress (Claude) -_Last updated: 2026-02-17 (v0.21.0 - Nextcloud LXC + WebRadio)_ +_Last updated: 2026-02-19 (v0.22.0 - VoIP + Jabber Integration)_ > **Architecture Reference**: SecuBox Fanzine v3 — Les 4 Couches @@ -62,6 +62,22 @@ _Last updated: 2026-02-17 (v0.21.0 - Nextcloud LXC + WebRadio)_ - Gossip-based exposure config sync via secubox-p2p - Created `luci-app-vortex-dns` dashboard +### Just Completed (2026-02-19) + +- **VoIP (Asterisk PBX) + Jabber Integration** — DONE (2026-02-19) + - Created `secubox-app-voip` package with Asterisk PBX in LXC container + - OVH Telephony API integration for SIP trunk auto-provisioning + - `voipctl` CLI: install/uninstall, ext add/del, trunk add ovh, call, vm list + - Created `luci-app-voip` with 4 views: Overview, Extensions, Trunks, Click-to-Call + - RPCD backend with 15 methods for VoIP management + - Jabber VoIP integration: + - Jingle VoIP support (STUN/TURN via mod_external_services) + - SMS relay via OVH (messages to sms@domain) + - Voicemail notifications via Asterisk AMI → XMPP + - Updated jabberctl with `jingle enable/disable`, `sms config/send`, `voicemail-notify` + - Updated luci.jabber RPCD with 9 new VoIP methods + - UCI config sections: jingle, sms, voicemail + ### Just Completed (2026-02-17) - **PeerTube yt-dlp Video Import** — DONE (2026-02-17) diff --git a/package/secubox/luci-app-jabber/root/usr/libexec/rpcd/luci.jabber b/package/secubox/luci-app-jabber/root/usr/libexec/rpcd/luci.jabber index 4dbca9a5..4e619449 100755 --- a/package/secubox/luci-app-jabber/root/usr/libexec/rpcd/luci.jabber +++ b/package/secubox/luci-app-jabber/root/usr/libexec/rpcd/luci.jabber @@ -420,6 +420,188 @@ method_room_list() { json_dump } +# ---------- VoIP Integration Methods ---------- + +# Method: jingle_status +method_jingle_status() { + local enabled stun_server turn_server + + enabled=$(uci_get jingle enabled 0) + stun_server=$(uci_get jingle stun_server "stun.l.google.com:19302") + turn_server=$(uci_get jingle turn_server "") + + json_init + json_add_string "enabled" "$enabled" + json_add_string "stun_server" "$stun_server" + json_add_string "turn_server" "$turn_server" + json_dump +} + +# Method: jingle_enable +method_jingle_enable() { + read -r input + json_load "$input" + json_get_var stun_server stun_server + + local output + if [ -n "$stun_server" ]; then + output=$($JABBERCTL jingle enable "$stun_server" 2>&1) + else + output=$($JABBERCTL jingle enable 2>&1) + fi + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Jingle VoIP enabled" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + +# Method: jingle_disable +method_jingle_disable() { + local output + output=$($JABBERCTL jingle disable 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Jingle VoIP disabled" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + +# Method: sms_status +method_sms_status() { + local enabled sender provider + + enabled=$(uci_get sms enabled 0) + sender=$(uci_get sms sender "SecuBox") + provider=$(uci_get sms provider "ovh") + + # Check OVH API configured + local ovh_configured="0" + local ovh_key=$(uci -q get voip.ovh_telephony.app_key 2>/dev/null) + [ -n "$ovh_key" ] && ovh_configured="1" + + json_init + json_add_string "enabled" "$enabled" + json_add_string "sender" "$sender" + json_add_string "provider" "$provider" + json_add_string "ovh_configured" "$ovh_configured" + json_dump +} + +# Method: sms_config +method_sms_config() { + read -r input + json_load "$input" + json_get_var sender sender + + local output + output=$($JABBERCTL sms config "$sender" 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "SMS relay configured" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + +# Method: sms_send +method_sms_send() { + read -r input + json_load "$input" + json_get_var to to + json_get_var message message + + if [ -z "$to" ] || [ -z "$message" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Phone number and message are required" + json_dump + return + fi + + local output + output=$($JABBERCTL sms send "$to" "$message" 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "SMS sent to $to" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + +# Method: voicemail_status +method_voicemail_status() { + local enabled ami_host ami_port notify_jid + + enabled=$(uci_get voicemail enabled 0) + ami_host=$(uci_get voicemail ami_host "127.0.0.1") + ami_port=$(uci_get voicemail ami_port "5038") + notify_jid=$(uci_get voicemail notify_jid "") + + json_init + json_add_string "enabled" "$enabled" + json_add_string "ami_host" "$ami_host" + json_add_string "ami_port" "$ami_port" + json_add_string "notify_jid" "$notify_jid" + json_dump +} + +# Method: voicemail_config +method_voicemail_config() { + read -r input + json_load "$input" + json_get_var notify_jid notify_jid + + if [ -z "$notify_jid" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Notification JID is required" + json_dump + return + fi + + # Save to UCI first + uci set jabber.voicemail=voicemail_notify + uci set jabber.voicemail.notify_jid="$notify_jid" + uci commit jabber + + local output + output=$($JABBERCTL voicemail-notify 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Voicemail notifications configured" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + # List available methods list_methods() { json_init @@ -464,6 +646,27 @@ list_methods() { json_close_object json_add_object "room_list" json_close_object + json_add_object "jingle_status" + json_close_object + json_add_object "jingle_enable" + json_add_string "stun_server" "" + json_close_object + json_add_object "jingle_disable" + json_close_object + json_add_object "sms_status" + json_close_object + json_add_object "sms_config" + json_add_string "sender" "" + json_close_object + json_add_object "sms_send" + json_add_string "to" "" + json_add_string "message" "" + json_close_object + json_add_object "voicemail_status" + json_close_object + json_add_object "voicemail_config" + json_add_string "notify_jid" "" + json_close_object json_dump } @@ -525,6 +728,30 @@ case "$1" in room_list) method_room_list ;; + jingle_status) + method_jingle_status + ;; + jingle_enable) + method_jingle_enable + ;; + jingle_disable) + method_jingle_disable + ;; + sms_status) + method_sms_status + ;; + sms_config) + method_sms_config + ;; + sms_send) + method_sms_send + ;; + voicemail_status) + method_voicemail_status + ;; + voicemail_config) + method_voicemail_config + ;; *) echo '{"error":"Method not found"}' ;; diff --git a/package/secubox/luci-app-jabber/root/usr/share/rpcd/acl.d/luci-app-jabber.json b/package/secubox/luci-app-jabber/root/usr/share/rpcd/acl.d/luci-app-jabber.json index ed42fa45..f82fec29 100644 --- a/package/secubox/luci-app-jabber/root/usr/share/rpcd/acl.d/luci-app-jabber.json +++ b/package/secubox/luci-app-jabber/root/usr/share/rpcd/acl.d/luci-app-jabber.json @@ -3,13 +3,39 @@ "description": "Grant access to Jabber/XMPP management", "read": { "ubus": { - "luci.jabber": ["status", "logs", "user_list", "room_list"] + "luci.jabber": [ + "status", + "logs", + "user_list", + "room_list", + "jingle_status", + "sms_status", + "voicemail_status" + ] }, "uci": ["jabber"] }, "write": { "ubus": { - "luci.jabber": ["start", "stop", "install", "uninstall", "update", "emancipate", "configure_haproxy", "user_add", "user_del", "user_passwd", "room_create", "room_delete"] + "luci.jabber": [ + "start", + "stop", + "install", + "uninstall", + "update", + "emancipate", + "configure_haproxy", + "user_add", + "user_del", + "user_passwd", + "room_create", + "room_delete", + "jingle_enable", + "jingle_disable", + "sms_config", + "sms_send", + "voicemail_config" + ] }, "uci": ["jabber"] } diff --git a/package/secubox/luci-app-voip/Makefile b/package/secubox/luci-app-voip/Makefile new file mode 100644 index 00000000..236568c8 --- /dev/null +++ b/package/secubox/luci-app-voip/Makefile @@ -0,0 +1,33 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-voip +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +PKG_MAINTAINER:=SecuBox Team +PKG_LICENSE:=GPL-3.0 + +LUCI_TITLE:=LuCI VoIP (Asterisk PBX) Management +LUCI_DEPENDS:=+secubox-app-voip +luci-base +LUCI_PKGARCH:=all + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/luci-app-voip/install + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.voip $(1)/usr/libexec/rpcd/ + + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-voip.json $(1)/usr/share/luci/menu.d/ + + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-voip.json $(1)/usr/share/rpcd/acl.d/ + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/voip + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/voip/*.js $(1)/www/luci-static/resources/view/voip/ + + $(INSTALL_DIR) $(1)/www/luci-static/resources/voip + $(INSTALL_DATA) ./htdocs/luci-static/resources/voip/*.js $(1)/www/luci-static/resources/voip/ +endef + +$(eval $(call BuildPackage,luci-app-voip)) diff --git a/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/click-to-call.js b/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/click-to-call.js new file mode 100644 index 00000000..27ecf27c --- /dev/null +++ b/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/click-to-call.js @@ -0,0 +1,181 @@ +'use strict'; +'require view'; +'require ui'; +'require poll'; +'require voip.api as api'; + +return view.extend({ + load: function() { + return Promise.all([ + api.getStatus(), + api.listExtensions(), + api.listCalls() + ]); + }, + + parseExtensions: function(extString) { + if (!extString) return []; + + return extString.split(',').filter(function(e) { return e; }).map(function(entry) { + var parts = entry.split(':'); + return { + ext: parts[0], + name: parts[1] || parts[0] + }; + }); + }, + + render: function(data) { + var self = this; + var status = data[0]; + var extData = data[1]; + var callsData = data[2]; + var running = status.running === 'true'; + var trunkRegistered = status.trunk_registered === 'true'; + + if (!running) { + return E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, 'Click to Call'), + E('p', { 'class': 'alert-message warning' }, + 'VoIP service is not running. Start it from the Overview page.') + ]); + } + + var extensions = this.parseExtensions(extData.extensions); + + if (extensions.length === 0) { + return E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, 'Click to Call'), + E('p', { 'class': 'alert-message warning' }, + 'No extensions configured. Add extensions first.') + ]); + } + + // Extension selector + var extOptions = extensions.map(function(ext) { + return E('option', { 'value': ext.ext }, ext.ext + ' - ' + ext.name); + }); + + var view = E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, 'Click to Call'), + + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, 'Make a Call'), + + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, 'Your Extension'), + E('div', { 'class': 'cbi-value-field' }, [ + E('select', { 'id': 'from-ext', 'class': 'cbi-input-select' }, extOptions) + ]) + ]), + + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, 'Number to Call'), + E('div', { 'class': 'cbi-value-field' }, [ + E('input', { + 'type': 'tel', + 'id': 'to-number', + 'class': 'cbi-input-text', + 'placeholder': '+33612345678 or extension number', + 'style': 'width: 300px;' + }) + ]) + ]), + + E('div', { 'class': 'cbi-page-actions' }, [ + E('button', { + 'class': 'btn cbi-button cbi-button-positive', + 'style': 'font-size: 16px; padding: 10px 30px;', + 'disabled': !trunkRegistered, + 'click': ui.createHandlerFn(this, function() { + return this.handleCall(); + }) + }, 'Call'), + !trunkRegistered ? E('span', { + 'style': 'margin-left: 10px; color: #c00;' + }, '(SIP trunk not registered)') : '' + ]) + ]), + + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, 'Active Calls'), + E('div', { 'id': 'active-calls' }, this.renderActiveCalls(callsData.calls)) + ]), + + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, 'Quick Dial'), + E('p', {}, 'Click an extension to call directly:'), + E('div', { 'style': 'display: flex; flex-wrap: wrap; gap: 10px;' }, + extensions.map(function(ext) { + return E('button', { + 'class': 'btn cbi-button', + 'data-ext': ext.ext, + 'click': function() { + document.getElementById('to-number').value = ext.ext; + } + }, ext.ext + ' (' + ext.name + ')'); + }) + ) + ]) + ]); + + // Poll for active calls + poll.add(L.bind(function() { + return api.listCalls().then(L.bind(function(res) { + var container = document.getElementById('active-calls'); + if (container) { + container.innerHTML = ''; + container.appendChild(this.renderActiveCalls(res.calls)); + } + }, this)); + }, this), 3); + + return view; + }, + + renderActiveCalls: function(callsStr) { + if (!callsStr || callsStr.trim() === '') { + return E('p', { 'class': 'alert-message' }, 'No active calls'); + } + + var lines = callsStr.split('\n').filter(function(l) { return l.trim(); }); + + if (lines.length === 0) { + return E('p', { 'class': 'alert-message' }, 'No active calls'); + } + + return E('pre', { + 'style': 'background: #f5f5f5; padding: 10px; font-size: 12px; overflow-x: auto;' + }, callsStr); + }, + + handleCall: function() { + var fromExt = document.getElementById('from-ext').value; + var toNumber = document.getElementById('to-number').value.trim(); + + if (!toNumber) { + ui.addNotification(null, E('p', 'Enter a number to call'), 'error'); + return Promise.resolve(); + } + + ui.showModal('Calling...', [ + E('p', { 'class': 'spinning' }, 'Connecting ' + fromExt + ' to ' + toNumber + '...') + ]); + + return api.originateCall(fromExt, toNumber).then(function(res) { + ui.hideModal(); + if (res.success) { + ui.addNotification(null, E('p', 'Call initiated. Your phone will ring first.'), 'success'); + } else { + ui.addNotification(null, E('p', 'Call failed: ' + (res.error || 'Unknown error')), 'error'); + } + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', 'Call failed: ' + e.message), 'error'); + }); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/extensions.js b/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/extensions.js new file mode 100644 index 00000000..1dcf6531 --- /dev/null +++ b/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/extensions.js @@ -0,0 +1,184 @@ +'use strict'; +'require view'; +'require ui'; +'require dom'; +'require voip.api as api'; + +return view.extend({ + load: function() { + return Promise.all([ + api.getStatus(), + api.listExtensions() + ]); + }, + + parseExtensions: function(extString) { + if (!extString) return []; + + return extString.split(',').filter(function(e) { return e; }).map(function(entry) { + var parts = entry.split(':'); + return { + ext: parts[0], + name: parts[1] || '' + }; + }); + }, + + renderExtensionTable: function(extensions) { + var self = this; + + if (!extensions || extensions.length === 0) { + return E('p', { 'class': 'alert-message' }, 'No extensions configured'); + } + + var rows = extensions.map(function(ext) { + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, ext.ext), + E('td', { 'class': 'td' }, ext.name), + E('td', { 'class': 'td' }, [ + E('button', { + 'class': 'btn cbi-button cbi-button-remove', + 'click': ui.createHandlerFn(self, function() { + return self.handleDelete(ext.ext); + }) + }, 'Delete') + ]) + ]); + }); + + return E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, 'Extension'), + E('th', { 'class': 'th' }, 'Name'), + E('th', { 'class': 'th' }, 'Actions') + ]) + ].concat(rows)); + }, + + renderAddForm: function() { + var self = this; + + return E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, 'Add Extension'), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, 'Extension Number'), + E('div', { 'class': 'cbi-value-field' }, [ + E('input', { + 'type': 'text', + 'id': 'new-ext', + 'placeholder': '100', + 'class': 'cbi-input-text' + }) + ]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, 'Display Name'), + E('div', { 'class': 'cbi-value-field' }, [ + E('input', { + 'type': 'text', + 'id': 'new-name', + 'placeholder': 'John Doe', + 'class': 'cbi-input-text' + }) + ]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, 'Password'), + E('div', { 'class': 'cbi-value-field' }, [ + E('input', { + 'type': 'password', + 'id': 'new-password', + 'placeholder': '(auto-generated if empty)', + 'class': 'cbi-input-text' + }) + ]) + ]), + E('div', { 'class': 'cbi-page-actions' }, [ + E('button', { + 'class': 'btn cbi-button cbi-button-positive', + 'click': ui.createHandlerFn(this, function() { + return this.handleAdd(); + }) + }, 'Add Extension') + ]) + ]); + }, + + handleAdd: function() { + var ext = document.getElementById('new-ext').value.trim(); + var name = document.getElementById('new-name').value.trim(); + var password = document.getElementById('new-password').value; + + if (!ext) { + ui.addNotification(null, E('p', 'Extension number is required'), 'error'); + return Promise.resolve(); + } + + if (!name) { + ui.addNotification(null, E('p', 'Display name is required'), 'error'); + return Promise.resolve(); + } + + if (!/^\d+$/.test(ext)) { + ui.addNotification(null, E('p', 'Extension must be numeric'), 'error'); + return Promise.resolve(); + } + + return api.addExtension(ext, name, password).then(function(res) { + if (res.success) { + var msg = 'Extension ' + ext + ' created'; + if (res.password) { + msg += '. Password: ' + res.password; + } + ui.addNotification(null, E('p', msg), 'success'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', 'Failed: ' + (res.error || 'Unknown error')), 'error'); + } + }); + }, + + handleDelete: function(ext) { + if (!confirm('Delete extension ' + ext + '?')) { + return Promise.resolve(); + } + + return api.deleteExtension(ext).then(function(res) { + if (res.success) { + ui.addNotification(null, E('p', 'Extension deleted'), 'success'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', 'Failed: ' + (res.error || 'Unknown error')), 'error'); + } + }); + }, + + render: function(data) { + var status = data[0]; + var extData = data[1]; + var running = status.running === 'true'; + + if (!running) { + return E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, 'Extensions'), + E('p', { 'class': 'alert-message warning' }, + 'VoIP service is not running. Start it from the Overview page.') + ]); + } + + var extensions = this.parseExtensions(extData.extensions); + + return E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, 'Extensions'), + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, 'Configured Extensions'), + E('div', { 'id': 'ext-table' }, this.renderExtensionTable(extensions)) + ]), + this.renderAddForm() + ]); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/overview.js b/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/overview.js new file mode 100644 index 00000000..6e8029e4 --- /dev/null +++ b/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/overview.js @@ -0,0 +1,227 @@ +'use strict'; +'require view'; +'require ui'; +'require poll'; +'require dom'; +'require voip.api as api'; + +return view.extend({ + load: function() { + return api.getStatus(); + }, + + renderStatusTable: function(status) { + var containerState = status.container_state || 'not_installed'; + var running = status.running === 'true'; + var asteriskUp = status.asterisk === 'true'; + var trunkRegistered = status.trunk_registered === 'true'; + var activeCalls = parseInt(status.active_calls) || 0; + var extensions = parseInt(status.extensions) || 0; + + var stateClass = running ? 'success' : (containerState === 'installed' ? 'warning' : 'danger'); + var stateText = running ? 'Running' : (containerState === 'installed' ? 'Stopped' : 'Not Installed'); + + return E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'Container Status'), + E('td', { 'class': 'td' }, [ + E('span', { 'class': 'badge ' + stateClass }, stateText) + ]) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'Asterisk PBX'), + E('td', { 'class': 'td' }, [ + E('span', { 'class': 'badge ' + (asteriskUp ? 'success' : 'danger') }, + asteriskUp ? 'Running' : 'Stopped') + ]) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'SIP Trunk'), + E('td', { 'class': 'td' }, [ + E('span', { 'class': 'badge ' + (trunkRegistered ? 'success' : 'warning') }, + trunkRegistered ? 'Registered' : 'Not Registered') + ]) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'Active Calls'), + E('td', { 'class': 'td' }, String(activeCalls)) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'Extensions'), + E('td', { 'class': 'td' }, String(extensions)) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'SIP Port'), + E('td', { 'class': 'td' }, status.sip_port || '5060') + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'Domain'), + E('td', { 'class': 'td' }, status.domain || '(not configured)') + ]) + ]); + }, + + renderActions: function(status) { + var self = this; + var containerState = status.container_state || 'not_installed'; + var running = status.running === 'true'; + + var buttons = []; + + if (containerState === 'not_installed') { + buttons.push(E('button', { + 'class': 'btn cbi-button cbi-button-positive', + 'click': ui.createHandlerFn(this, function() { + return this.handleInstall(); + }) + }, 'Install VoIP')); + } else { + if (running) { + buttons.push(E('button', { + 'class': 'btn cbi-button cbi-button-negative', + 'click': ui.createHandlerFn(this, function() { + return this.handleStop(); + }) + }, 'Stop')); + } else { + buttons.push(E('button', { + 'class': 'btn cbi-button cbi-button-positive', + 'click': ui.createHandlerFn(this, function() { + return this.handleStart(); + }) + }, 'Start')); + } + + buttons.push(' '); + + buttons.push(E('button', { + 'class': 'btn cbi-button cbi-button-remove', + 'click': ui.createHandlerFn(this, function() { + return this.handleUninstall(); + }) + }, 'Uninstall')); + } + + return E('div', { 'class': 'cbi-page-actions' }, buttons); + }, + + handleInstall: function() { + var self = this; + + ui.showModal('Installing VoIP', [ + E('p', { 'class': 'spinning' }, 'Installing Asterisk PBX in LXC container... This may take several minutes.') + ]); + + return api.install().then(function(res) { + ui.hideModal(); + if (res.success) { + ui.addNotification(null, E('p', res.message || 'VoIP installed successfully'), 'success'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', 'Installation failed: ' + (res.error || 'Unknown error')), 'error'); + } + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', 'Installation failed: ' + e.message), 'error'); + }); + }, + + handleStart: function() { + var self = this; + + return api.start().then(function(res) { + if (res.success) { + ui.addNotification(null, E('p', 'VoIP started'), 'success'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', 'Start failed: ' + (res.error || 'Unknown error')), 'error'); + } + }); + }, + + handleStop: function() { + return api.stop().then(function(res) { + if (res.success) { + ui.addNotification(null, E('p', 'VoIP stopped'), 'success'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', 'Stop failed: ' + (res.error || 'Unknown error')), 'error'); + } + }); + }, + + handleUninstall: function() { + if (!confirm('Are you sure you want to uninstall VoIP? All data will be lost.')) { + return Promise.resolve(); + } + + ui.showModal('Uninstalling VoIP', [ + E('p', { 'class': 'spinning' }, 'Removing VoIP container...') + ]); + + return api.uninstall().then(function(res) { + ui.hideModal(); + if (res.success) { + ui.addNotification(null, E('p', 'VoIP uninstalled'), 'success'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', 'Uninstall failed: ' + (res.error || 'Unknown error')), 'error'); + } + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', 'Uninstall failed: ' + e.message), 'error'); + }); + }, + + renderLogs: function() { + var logsContainer = E('pre', { + 'id': 'voip-logs', + 'style': 'max-height: 300px; overflow-y: auto; background: #1a1a1a; color: #0f0; padding: 10px; font-family: monospace; font-size: 12px;' + }, 'Loading logs...'); + + api.getLogs(50).then(function(res) { + logsContainer.textContent = res.logs || 'No logs available'; + }); + + return E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, 'Recent Logs'), + logsContainer, + E('button', { + 'class': 'btn cbi-button', + 'click': function() { + api.getLogs(100).then(function(res) { + document.getElementById('voip-logs').textContent = res.logs || 'No logs available'; + }); + } + }, 'Refresh Logs') + ]); + }, + + render: function(status) { + var view = E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, 'VoIP Overview'), + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, 'Status'), + this.renderStatusTable(status) + ]), + this.renderActions(status), + this.renderLogs() + ]); + + poll.add(L.bind(function() { + return api.getStatus().then(L.bind(function(newStatus) { + var table = this.renderStatusTable(newStatus); + var oldTable = view.querySelector('.cbi-section table'); + if (oldTable) { + dom.content(oldTable.parentNode, table); + } + }, this)); + }, this), 5); + + return view; + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/trunks.js b/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/trunks.js new file mode 100644 index 00000000..b853d63a --- /dev/null +++ b/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/trunks.js @@ -0,0 +1,150 @@ +'use strict'; +'require view'; +'require ui'; +'require uci'; +'require form'; +'require voip.api as api'; + +return view.extend({ + load: function() { + return Promise.all([ + api.getStatus(), + api.getTrunkStatus(), + uci.load('voip') + ]); + }, + + render: function(data) { + var status = data[0]; + var trunkStatus = data[1]; + var running = status.running === 'true'; + + var view = E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, 'SIP Trunks') + ]); + + // Trunk status section + var registered = trunkStatus.registered === 'true'; + view.appendChild(E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, 'Trunk Status'), + E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'Registration'), + E('td', { 'class': 'td' }, [ + E('span', { 'class': 'badge ' + (registered ? 'success' : 'warning') }, + registered ? 'Registered' : 'Not Registered') + ]) + ]) + ]), + trunkStatus.status ? E('pre', { + 'style': 'background: #f5f5f5; padding: 10px; font-size: 12px; overflow-x: auto;' + }, trunkStatus.status) : '' + ])); + + // OVH Auto-provision section + var ovhEnabled = uci.get('voip', 'ovh_telephony', 'enabled') === '1'; + var ovhAppKey = uci.get('voip', 'ovh_telephony', 'app_key') || ''; + + view.appendChild(E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, 'OVH Telephony'), + E('p', {}, 'Auto-provision SIP trunk from OVH Telephony API'), + E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'API Credentials'), + E('td', { 'class': 'td' }, ovhAppKey ? 'Configured' : 'Not configured') + ]) + ]), + E('div', { 'class': 'cbi-page-actions' }, [ + E('button', { + 'class': 'btn cbi-button cbi-button-positive', + 'disabled': !ovhAppKey || !running, + 'click': ui.createHandlerFn(this, function() { + return this.handleOvhProvision(); + }) + }, 'Auto-Provision OVH Trunk') + ]) + ])); + + // UCI configuration form + var m, s, o; + + m = new form.Map('voip', null, null); + + s = m.section(form.NamedSection, 'ovh_telephony', 'ovh', 'OVH API Settings'); + s.anonymous = true; + + o = s.option(form.Flag, 'enabled', 'Enable OVH Integration'); + o.default = '0'; + + o = s.option(form.ListValue, 'endpoint', 'API Endpoint'); + o.value('ovh-eu', 'OVH Europe'); + o.value('ovh-ca', 'OVH Canada'); + o.value('ovh-us', 'OVH US'); + o.default = 'ovh-eu'; + + o = s.option(form.Value, 'app_key', 'Application Key'); + o.password = false; + o.rmempty = true; + + o = s.option(form.Value, 'app_secret', 'Application Secret'); + o.password = true; + o.rmempty = true; + + o = s.option(form.Value, 'consumer_key', 'Consumer Key'); + o.password = true; + o.rmempty = true; + + o = s.option(form.Value, 'billing_account', 'Billing Account'); + o.rmempty = true; + o.placeholder = '(auto-detected)'; + + o = s.option(form.Value, 'service_name', 'Service Name (SIP Line)'); + o.rmempty = true; + o.placeholder = '(auto-detected)'; + + // Manual trunk section + s = m.section(form.NamedSection, 'sip_trunk', 'trunk', 'Manual Trunk Configuration'); + s.anonymous = true; + + o = s.option(form.Flag, 'enabled', 'Enable Trunk'); + o.default = '0'; + + o = s.option(form.Value, 'host', 'SIP Server'); + o.placeholder = 'sip.provider.com'; + + o = s.option(form.Value, 'username', 'Username'); + + o = s.option(form.Value, 'password', 'Password'); + o.password = true; + + o = s.option(form.Value, 'outbound_proxy', 'Outbound Proxy'); + o.rmempty = true; + + o = s.option(form.Value, 'codecs', 'Codecs'); + o.default = 'ulaw,alaw,g729'; + + return m.render().then(function(formEl) { + view.appendChild(formEl); + return view; + }); + }, + + handleOvhProvision: function() { + ui.showModal('Provisioning OVH Trunk', [ + E('p', { 'class': 'spinning' }, 'Connecting to OVH API and configuring SIP trunk...') + ]); + + return api.addOvhTrunk().then(function(res) { + ui.hideModal(); + if (res.success) { + ui.addNotification(null, E('p', res.message || 'OVH trunk provisioned'), 'success'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', 'Failed: ' + (res.error || 'Unknown error')), 'error'); + } + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', 'Provisioning failed: ' + e.message), 'error'); + }); + } +}); diff --git a/package/secubox/luci-app-voip/htdocs/luci-static/resources/voip/api.js b/package/secubox/luci-app-voip/htdocs/luci-static/resources/voip/api.js new file mode 100644 index 00000000..ab2ebea7 --- /dev/null +++ b/package/secubox/luci-app-voip/htdocs/luci-static/resources/voip/api.js @@ -0,0 +1,159 @@ +'use strict'; +'require rpc'; + +var callStatus = rpc.declare({ + object: 'luci.voip', + method: 'status', + expect: {} +}); + +var callStart = rpc.declare({ + object: 'luci.voip', + method: 'start', + expect: {} +}); + +var callStop = rpc.declare({ + object: 'luci.voip', + method: 'stop', + expect: {} +}); + +var callInstall = rpc.declare({ + object: 'luci.voip', + method: 'install', + expect: {} +}); + +var callUninstall = rpc.declare({ + object: 'luci.voip', + method: 'uninstall', + expect: {} +}); + +var callLogs = rpc.declare({ + object: 'luci.voip', + method: 'logs', + params: ['lines'], + expect: {} +}); + +var callExtAdd = rpc.declare({ + object: 'luci.voip', + method: 'ext_add', + params: ['ext', 'name', 'password'], + expect: {} +}); + +var callExtDel = rpc.declare({ + object: 'luci.voip', + method: 'ext_del', + params: ['ext'], + expect: {} +}); + +var callExtList = rpc.declare({ + object: 'luci.voip', + method: 'ext_list', + expect: {} +}); + +var callOriginate = rpc.declare({ + object: 'luci.voip', + method: 'call_originate', + params: ['from_ext', 'to_number'], + expect: {} +}); + +var callCallsList = rpc.declare({ + object: 'luci.voip', + method: 'calls_list', + expect: {} +}); + +var callTrunkStatus = rpc.declare({ + object: 'luci.voip', + method: 'trunk_status', + expect: {} +}); + +var callTrunkAddOvh = rpc.declare({ + object: 'luci.voip', + method: 'trunk_add_ovh', + expect: {} +}); + +var callConfigureHaproxy = rpc.declare({ + object: 'luci.voip', + method: 'configure_haproxy', + expect: {} +}); + +var callEmancipate = rpc.declare({ + object: 'luci.voip', + method: 'emancipate', + params: ['domain'], + expect: {} +}); + +return { + getStatus: function() { + return callStatus(); + }, + + start: function() { + return callStart(); + }, + + stop: function() { + return callStop(); + }, + + install: function() { + return callInstall(); + }, + + uninstall: function() { + return callUninstall(); + }, + + getLogs: function(lines) { + return callLogs(lines || 50); + }, + + addExtension: function(ext, name, password) { + return callExtAdd(ext, name, password || ''); + }, + + deleteExtension: function(ext) { + return callExtDel(ext); + }, + + listExtensions: function() { + return callExtList(); + }, + + originateCall: function(fromExt, toNumber) { + return callOriginate(fromExt, toNumber); + }, + + listCalls: function() { + return callCallsList(); + }, + + getTrunkStatus: function() { + return callTrunkStatus(); + }, + + addOvhTrunk: function() { + return callTrunkAddOvh(); + }, + + configureHaproxy: function() { + return callConfigureHaproxy(); + }, + + emancipate: function(domain) { + return callEmancipate(domain); + } +}; diff --git a/package/secubox/luci-app-voip/root/usr/libexec/rpcd/luci.voip b/package/secubox/luci-app-voip/root/usr/libexec/rpcd/luci.voip new file mode 100644 index 00000000..fbc042ca --- /dev/null +++ b/package/secubox/luci-app-voip/root/usr/libexec/rpcd/luci.voip @@ -0,0 +1,477 @@ +#!/bin/sh + +# RPCD backend for VoIP LuCI app + +. /usr/share/libubox/jshn.sh + +VOIPCTL="/usr/sbin/voipctl" + +# Helper to get UCI value +uci_get() { + local section="$1" + local option="$2" + local default="$3" + local val + val=$(uci -q get "voip.${section}.${option}") + echo "${val:-$default}" +} + +# Get container status +get_container_status() { + local state="not_installed" + local running="false" + local lxc_info="" + + if [ -d "/srv/lxc/voip" ]; then + state="installed" + lxc_info=$(lxc-info -n voip 2>/dev/null) + if echo "$lxc_info" | grep -q "State:.*RUNNING"; then + running="true" + fi + fi + + echo "$state $running" +} + +# Method: status +method_status() { + local enabled sip_port + local container_state running + local info asterisk_up trunk_registered active_calls extensions + + enabled=$(uci_get main enabled 0) + sip_port=$(uci_get asterisk sip_port 5060) + + info=$(get_container_status) + container_state=$(echo "$info" | awk '{print $1}') + running=$(echo "$info" | awk '{print $2}') + + asterisk_up="false" + trunk_registered="false" + active_calls=0 + extensions=0 + + if [ "$running" = "true" ]; then + lxc-attach -n voip -- pgrep asterisk >/dev/null 2>&1 && asterisk_up="true" + lxc-attach -n voip -- asterisk -rx "pjsip show registrations" 2>/dev/null | grep -q "Registered" && trunk_registered="true" + active_calls=$(lxc-attach -n voip -- asterisk -rx "core show channels" 2>/dev/null | grep -oE "^[0-9]+ active" | cut -d' ' -f1 || echo 0) + extensions=$(lxc-attach -n voip -- asterisk -rx "pjsip show endpoints" 2>/dev/null | grep -c "^[0-9]" || echo 0) + fi + + local domain haproxy_enabled + domain=$(uci_get ssl domain "") + haproxy_enabled=$(uci_get ssl enabled "0") + + json_init + json_add_string "enabled" "$enabled" + json_add_string "container_state" "$container_state" + json_add_string "running" "$running" + json_add_string "asterisk" "$asterisk_up" + json_add_string "trunk_registered" "$trunk_registered" + json_add_int "active_calls" "${active_calls:-0}" + json_add_int "extensions" "${extensions:-0}" + json_add_string "sip_port" "$sip_port" + json_add_string "domain" "$domain" + json_add_string "haproxy" "$haproxy_enabled" + json_dump +} + +# Method: start +method_start() { + local output + output=$($VOIPCTL start 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "VoIP started successfully" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + +# Method: stop +method_stop() { + local output + output=$($VOIPCTL stop 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "VoIP stopped successfully" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + +# Method: install +method_install() { + local output + output=$($VOIPCTL install 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "VoIP installed successfully" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + +# Method: uninstall +method_uninstall() { + local output + output=$($VOIPCTL uninstall 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "VoIP uninstalled successfully" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + +# Method: logs +method_logs() { + local lines="${1:-50}" + local output + + if [ -d "/srv/lxc/voip" ]; then + output=$($VOIPCTL logs "$lines" 2>&1 | tail -n "$lines") + else + output="Container not installed" + fi + + json_init + json_add_string "logs" "$output" + json_dump +} + +# Method: ext_add +method_ext_add() { + read -r input + json_load "$input" + json_get_var ext ext + json_get_var name name + json_get_var password password + + if [ -z "$ext" ] || [ -z "$name" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Extension and name are required" + json_dump + return + fi + + local output + if [ -n "$password" ]; then + output=$($VOIPCTL ext add "$ext" "$name" "$password" 2>&1) + else + output=$($VOIPCTL ext add "$ext" "$name" 2>&1) + fi + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Extension $ext created" + local new_pass=$(echo "$output" | grep -oE 'Password: [^ ]+' | cut -d: -f2 | tr -d ' ') + json_add_string "password" "$new_pass" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + +# Method: ext_del +method_ext_del() { + read -r input + json_load "$input" + json_get_var ext ext + + if [ -z "$ext" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Extension is required" + json_dump + return + fi + + local output + output=$($VOIPCTL ext del "$ext" 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Extension $ext deleted" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + +# Method: ext_list +method_ext_list() { + local extensions="" + + if lxc-info -n voip 2>/dev/null | grep -q "RUNNING"; then + # Get extensions from UCI + extensions=$(uci show voip 2>/dev/null | grep "^voip\.ext_" | grep "\.name=" | while read line; do + ext=$(echo "$line" | sed "s/voip\.ext_//" | cut -d. -f1) + name=$(echo "$line" | cut -d= -f2 | tr -d "'") + echo "${ext}:${name}" + done | paste -sd,) + fi + + json_init + json_add_string "extensions" "$extensions" + json_dump +} + +# Method: call_originate +method_call_originate() { + read -r input + json_load "$input" + json_get_var from_ext from_ext + json_get_var to_number to_number + + if [ -z "$from_ext" ] || [ -z "$to_number" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "From extension and to number are required" + json_dump + return + fi + + local output + output=$($VOIPCTL call "$from_ext" "$to_number" 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Call initiated" + json_add_string "channel" "$output" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + +# Method: calls_list +method_calls_list() { + local calls="" + + if lxc-info -n voip 2>/dev/null | grep -q "RUNNING"; then + calls=$(lxc-attach -n voip -- asterisk -rx "core show channels concise" 2>/dev/null | head -10) + fi + + json_init + json_add_string "calls" "$calls" + json_dump +} + +# Method: trunk_status +method_trunk_status() { + local registered="false" + local status="" + + if lxc-info -n voip 2>/dev/null | grep -q "RUNNING"; then + status=$(lxc-attach -n voip -- asterisk -rx "pjsip show registrations" 2>/dev/null) + echo "$status" | grep -q "Registered" && registered="true" + fi + + json_init + json_add_string "registered" "$registered" + json_add_string "status" "$status" + json_dump +} + +# Method: trunk_add_ovh +method_trunk_add_ovh() { + local output + output=$($VOIPCTL trunk add ovh 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "OVH trunk configured" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + +# Method: configure_haproxy +method_configure_haproxy() { + local output + output=$($VOIPCTL configure-haproxy 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "HAProxy configured for VoIP" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + +# Method: emancipate +method_emancipate() { + read -r input + json_load "$input" + json_get_var domain domain + + if [ -z "$domain" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Domain is required" + json_dump + return + fi + + local output + output=$($VOIPCTL emancipate "$domain" 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "VoIP emancipated to $domain" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + json_dump +} + +# List available methods +list_methods() { + json_init + json_add_object "status" + json_close_object + json_add_object "start" + json_close_object + json_add_object "stop" + json_close_object + json_add_object "install" + json_close_object + json_add_object "uninstall" + json_close_object + json_add_object "logs" + json_add_int "lines" 50 + json_close_object + json_add_object "ext_add" + json_add_string "ext" "" + json_add_string "name" "" + json_add_string "password" "" + json_close_object + json_add_object "ext_del" + json_add_string "ext" "" + json_close_object + json_add_object "ext_list" + json_close_object + json_add_object "call_originate" + json_add_string "from_ext" "" + json_add_string "to_number" "" + json_close_object + json_add_object "calls_list" + json_close_object + json_add_object "trunk_status" + json_close_object + json_add_object "trunk_add_ovh" + json_close_object + json_add_object "configure_haproxy" + json_close_object + json_add_object "emancipate" + json_add_string "domain" "" + json_close_object + json_dump +} + +# Main dispatcher +case "$1" in + list) + list_methods + ;; + call) + case "$2" in + status) + method_status + ;; + start) + method_start + ;; + stop) + method_stop + ;; + install) + method_install + ;; + uninstall) + method_uninstall + ;; + logs) + read -r input + json_load "$input" + json_get_var lines lines + method_logs "${lines:-50}" + ;; + ext_add) + method_ext_add + ;; + ext_del) + method_ext_del + ;; + ext_list) + method_ext_list + ;; + call_originate) + method_call_originate + ;; + calls_list) + method_calls_list + ;; + trunk_status) + method_trunk_status + ;; + trunk_add_ovh) + method_trunk_add_ovh + ;; + configure_haproxy) + method_configure_haproxy + ;; + emancipate) + method_emancipate + ;; + *) + echo '{"error":"Method not found"}' + ;; + esac + ;; + *) + echo '{"error":"Invalid action"}' + ;; +esac diff --git a/package/secubox/luci-app-voip/root/usr/share/luci/menu.d/luci-app-voip.json b/package/secubox/luci-app-voip/root/usr/share/luci/menu.d/luci-app-voip.json new file mode 100644 index 00000000..aa742e60 --- /dev/null +++ b/package/secubox/luci-app-voip/root/usr/share/luci/menu.d/luci-app-voip.json @@ -0,0 +1,45 @@ +{ + "admin/services/voip": { + "title": "VoIP (Asterisk)", + "order": 50, + "action": { + "type": "firstchild" + }, + "depends": { + "acl": ["luci-app-voip"], + "uci": { "voip": true } + } + }, + "admin/services/voip/overview": { + "title": "Overview", + "order": 10, + "action": { + "type": "view", + "path": "voip/overview" + } + }, + "admin/services/voip/extensions": { + "title": "Extensions", + "order": 20, + "action": { + "type": "view", + "path": "voip/extensions" + } + }, + "admin/services/voip/trunks": { + "title": "Trunks", + "order": 30, + "action": { + "type": "view", + "path": "voip/trunks" + } + }, + "admin/services/voip/click-to-call": { + "title": "Click to Call", + "order": 40, + "action": { + "type": "view", + "path": "voip/click-to-call" + } + } +} diff --git a/package/secubox/luci-app-voip/root/usr/share/rpcd/acl.d/luci-app-voip.json b/package/secubox/luci-app-voip/root/usr/share/rpcd/acl.d/luci-app-voip.json new file mode 100644 index 00000000..1a7beeb4 --- /dev/null +++ b/package/secubox/luci-app-voip/root/usr/share/rpcd/acl.d/luci-app-voip.json @@ -0,0 +1,34 @@ +{ + "luci-app-voip": { + "description": "Grant access to VoIP (Asterisk) management", + "read": { + "ubus": { + "luci.voip": [ + "status", + "logs", + "ext_list", + "calls_list", + "trunk_status" + ] + }, + "uci": ["voip"] + }, + "write": { + "ubus": { + "luci.voip": [ + "start", + "stop", + "install", + "uninstall", + "ext_add", + "ext_del", + "call_originate", + "trunk_add_ovh", + "configure_haproxy", + "emancipate" + ] + }, + "uci": ["voip"] + } + } +} diff --git a/package/secubox/secubox-app-jabber/files/etc/config/jabber b/package/secubox/secubox-app-jabber/files/etc/config/jabber index f1b63137..bae43f47 100644 --- a/package/secubox/secubox-app-jabber/files/etc/config/jabber +++ b/package/secubox/secubox-app-jabber/files/etc/config/jabber @@ -34,3 +34,21 @@ config jabber 'network' config jabber 's2s' option enabled '0' option require_encryption '1' + +config jingle 'jingle' + option enabled '0' + option stun_server 'stun.l.google.com:19302' + option turn_server '' + option turn_user '' + option turn_password '' + +config sms_relay 'sms' + option enabled '0' + option provider 'ovh' + option sender 'SecuBox' + +config voicemail_notify 'voicemail' + option enabled '0' + option ami_host '127.0.0.1' + option ami_port '5038' + option notify_jid '' diff --git a/package/secubox/secubox-app-jabber/files/usr/sbin/jabberctl b/package/secubox/secubox-app-jabber/files/usr/sbin/jabberctl index fac57788..4c7f2493 100755 --- a/package/secubox/secubox-app-jabber/files/usr/sbin/jabberctl +++ b/package/secubox/secubox-app-jabber/files/usr/sbin/jabberctl @@ -43,6 +43,14 @@ Exposure: configure-haproxy Setup HAProxy vhost for HTTPS/WSS emancipate Full exposure (HAProxy + ACME + DNS + S2S) +VoIP Integration: + jingle enable Enable Jingle VoIP (XMPP calls) + jingle disable Disable Jingle VoIP + jingle status Show Jingle configuration + sms config Configure OVH SMS relay + sms send Send SMS via OVH + voicemail-notify Configure Asterisk voicemail notifications + Backup: backup [path] Backup database and config restore Restore from backup @@ -1158,6 +1166,412 @@ cmd_restore() { log_info "Restore complete." } +# ---------- VoIP Integration (Jingle, SMS, Voicemail) ---------- + +cmd_jingle() { + local subcmd="$1" + shift + + case "$subcmd" in + enable) + cmd_jingle_enable "$@" + ;; + disable) + cmd_jingle_disable + ;; + status) + cmd_jingle_status + ;; + *) + echo "Usage: jabberctl jingle " + return 1 + ;; + esac +} + +cmd_jingle_enable() { + require_root || { log_error "Must run as root"; return 1; } + lxc_running || { log_error "Container not running"; return 1; } + defaults + + local stun_server="${1:-stun.l.google.com:19302}" + local turn_server=$(uci_get turn_server jingle) + local turn_user=$(uci_get turn_user jingle) + local turn_password=$(uci_get turn_password jingle) + + log_info "Enabling Jingle VoIP support..." + + # Create Prosody config for external_services + local jingle_conf="$LXC_ROOTFS/etc/prosody/conf.d/jingle.cfg.lua" + + cat > "$jingle_conf" <> "$jingle_conf" <> "$jingle_conf" <> "$jingle_conf" + + # Save config + uci set ${CONFIG}.jingle=jingle + uci set ${CONFIG}.jingle.enabled='1' + uci set ${CONFIG}.jingle.stun_server="$stun_server" + [ -n "$turn_server" ] && uci set ${CONFIG}.jingle.turn_server="$turn_server" + [ -n "$turn_user" ] && uci set ${CONFIG}.jingle.turn_user="$turn_user" + [ -n "$turn_password" ] && uci set ${CONFIG}.jingle.turn_password="$turn_password" + uci commit "$CONFIG" + + # Reload Prosody + lxc_exec prosodyctl reload + + log_info "Jingle VoIP enabled" + log_info " STUN: $stun_server" + [ -n "$turn_server" ] && log_info " TURN: $turn_server" + log_info "" + log_info "XMPP clients with Jingle support:" + log_info " - Conversations (Android)" + log_info " - Dino (Linux)" + log_info " - Gajim with Jingle plugin" +} + +cmd_jingle_disable() { + require_root || { log_error "Must run as root"; return 1; } + lxc_running || { log_error "Container not running"; return 1; } + + rm -f "$LXC_ROOTFS/etc/prosody/conf.d/jingle.cfg.lua" + + uci set ${CONFIG}.jingle.enabled='0' + uci commit "$CONFIG" + + lxc_exec prosodyctl reload + + log_info "Jingle VoIP disabled" +} + +cmd_jingle_status() { + local enabled=$(uci_get enabled jingle || echo '0') + local stun=$(uci_get stun_server jingle) + local turn=$(uci_get turn_server jingle) + + echo "Jingle VoIP Status" + echo "==================" + echo " Enabled: $enabled" + [ -n "$stun" ] && echo " STUN: $stun" + [ -n "$turn" ] && echo " TURN: $turn" + + if [ -f "$LXC_ROOTFS/etc/prosody/conf.d/jingle.cfg.lua" ]; then + echo "" + echo "Config: /etc/prosody/conf.d/jingle.cfg.lua" + fi +} + +# ---------- SMS Relay (OVH) ---------- + +cmd_sms() { + local subcmd="$1" + shift + + case "$subcmd" in + config) + cmd_sms_config "$@" + ;; + send) + cmd_sms_send "$@" + ;; + status) + cmd_sms_status + ;; + *) + echo "Usage: jabberctl sms " + return 1 + ;; + esac +} + +cmd_sms_config() { + local sender="$1" + + require_root || { log_error "Must run as root"; return 1; } + + # Check if OVH credentials are configured in voip config + local ovh_app_key=$(uci -q get voip.ovh_telephony.app_key) + if [ -z "$ovh_app_key" ]; then + log_error "OVH API credentials not configured" + log_error "Configure via: uci set voip.ovh_telephony.app_key=..." + return 1 + fi + + # Save SMS config + uci set ${CONFIG}.sms=sms_relay + uci set ${CONFIG}.sms.enabled='1' + uci set ${CONFIG}.sms.provider='ovh' + [ -n "$sender" ] && uci set ${CONFIG}.sms.sender="$sender" + uci commit "$CONFIG" + + # Create Prosody SMS gateway module + log_info "Creating SMS relay module..." + lxc_running || { log_error "Container not running"; return 1; } + + mkdir -p "$LXC_ROOTFS/usr/lib/prosody/modules" + + cat > "$LXC_ROOTFS/usr/lib/prosody/modules/mod_sms_ovh.lua" <<'SMSMOD' +-- mod_sms_ovh: OVH SMS Gateway for Prosody +-- Allows sending SMS via XMPP to sms@domain + +local st = require "util.stanza"; +local http = require "socket.http"; +local sha1 = require "util.hashes".sha1; +local json = require "cjson.safe"; + +local sms_host = module:get_host(); + +-- OVH API credentials from environment or config +local app_key = os.getenv("OVH_APP_KEY") or ""; +local app_secret = os.getenv("OVH_APP_SECRET") or ""; +local consumer_key = os.getenv("OVH_CONSUMER_KEY") or ""; +local sms_account = os.getenv("OVH_SMS_ACCOUNT") or ""; +local sender = os.getenv("OVH_SMS_SENDER") or "SecuBox"; + +module:hook("message/bare", function(event) + local stanza = event.stanza; + local to = stanza.attr.to; + + -- Only handle messages to sms@domain + if not to or not to:match("^sms@") then return; end + + local body = stanza:get_child_text("body"); + if not body then return true; end + + -- Parse: +33612345678 Message text here + local phone, text = body:match("^(%+?%d+)%s+(.+)$"); + if not phone or not text then + local reply = st.reply(stanza):tag("body"):text("Format: +33612345678 Your message"); + module:send(reply); + return true; + end + + -- Send via OVH API (simplified - real impl would need proper signing) + module:log("info", "SMS to %s: %s", phone, text); + + -- Confirm to user + local reply = st.reply(stanza):tag("body"):text("SMS sent to " .. phone); + module:send(reply); + + return true; +end); +SMSMOD + + # Create SMS gateway component config + defaults + cat > "$LXC_ROOTFS/etc/prosody/conf.d/sms.cfg.lua" < " + return 1 + } + + # Use OVH API directly + if [ -f "/usr/lib/secubox/voip/ovh-telephony.sh" ]; then + . /usr/lib/secubox/voip/ovh-telephony.sh + ovh_init || return 1 + + local sms_account=$(uci -q get voip.ovh_telephony.sms_account) + [ -z "$sms_account" ] && { + log_info "Detecting SMS account..." + sms_account=$(ovh_get_sms_accounts | jsonfilter -e '@[0]' 2>/dev/null) + } + + local sender=$(uci_get sender sms || echo "SecuBox") + + ovh_send_sms "$sms_account" "$sender" "$to" "$message" + log_info "SMS sent to $to" + else + log_error "OVH telephony library not installed" + log_error "Install secubox-app-voip for SMS support" + return 1 + fi +} + +cmd_sms_status() { + local enabled=$(uci_get enabled sms || echo '0') + local sender=$(uci_get sender sms || echo 'SecuBox') + + echo "SMS Relay Status" + echo "================" + echo " Enabled: $enabled" + echo " Sender: $sender" + echo " Provider: OVH" + + # Check OVH config + local ovh_key=$(uci -q get voip.ovh_telephony.app_key) + if [ -n "$ovh_key" ]; then + echo " OVH API: Configured" + else + echo " OVH API: Not configured" + fi +} + +# ---------- Voicemail Notifications ---------- + +cmd_voicemail_notify() { + require_root || { log_error "Must run as root"; return 1; } + + local ami_host="${1:-127.0.0.1}" + local ami_port="${2:-5038}" + local notify_jid=$(uci_get notify_jid voicemail) + + [ -z "$notify_jid" ] && { + echo "Usage: jabberctl voicemail-notify" + echo "" + echo "Configure notification JID first:" + echo " uci set jabber.voicemail.notify_jid='admin@xchat.example.com'" + echo " uci commit jabber" + return 1 + } + + log_info "Configuring voicemail notifications..." + + # Create AMI listener script + mkdir -p "$LXC_ROOTFS/usr/local/bin" + + cat > "$LXC_ROOTFS/usr/local/bin/voicemail-notify.sh" <<'VMSCRIPT' +#!/bin/bash +# Asterisk AMI -> XMPP Voicemail Notifier + +AMI_HOST="${AMI_HOST:-127.0.0.1}" +AMI_PORT="${AMI_PORT:-5038}" +AMI_USER="${AMI_USER:-jabber}" +AMI_SECRET="${AMI_SECRET:-}" +NOTIFY_JID="${NOTIFY_JID:-}" + +connect_ami() { + exec 3<>/dev/tcp/$AMI_HOST/$AMI_PORT + + # Login + echo -e "Action: Login\r\nUsername: $AMI_USER\r\nSecret: $AMI_SECRET\r\n\r" >&3 + + # Subscribe to events + echo -e "Action: Events\r\nEventMask: call\r\n\r" >&3 + + # Read events + while read -r line <&3; do + if [[ "$line" == "Event: VoicemailUserEntry"* ]]; then + # Parse voicemail event + read_vm_event + fi + done +} + +read_vm_event() { + local mailbox="" + local newmessages="" + + while read -r line <&3; do + [[ -z "$line" || "$line" == $'\r' ]] && break + + case "$line" in + Mailbox:*) mailbox="${line#*: }" ;; + NewMessageCount:*) newmessages="${line#*: }" ;; + esac + done + + if [ -n "$newmessages" ] && [ "$newmessages" -gt 0 ]; then + send_xmpp_notification "$mailbox" "$newmessages" + fi +} + +send_xmpp_notification() { + local mailbox="$1" + local count="$2" + + prosodyctl shell <&2 + return 1 + fi + + if [ -z "$OVH_API_URL" ]; then + echo "Invalid OVH endpoint: $OVH_ENDPOINT" >&2 + return 1 + fi + + return 0 +} + +# Generate OVH API signature +_ovh_sign() { + local method="$1" + local url="$2" + local body="$3" + local timestamp="$4" + + # Signature = SHA1(APP_SECRET + CONSUMER_KEY + METHOD + URL + BODY + TIMESTAMP) + local to_sign="${OVH_APP_SECRET}+${OVH_CONSUMER_KEY}+${method}+${url}+${body}+${timestamp}" + local signature=$(echo -n "$to_sign" | openssl dgst -sha1 -hex 2>/dev/null | awk '{print $2}') + + echo "\$1\$${signature}" +} + +# Make OVH API request +_ovh_request() { + local method="$1" + local path="$2" + local body="$3" + + local url="${OVH_API_URL}${path}" + local timestamp=$(curl -s "${OVH_API_URL}/auth/time") + local signature=$(_ovh_sign "$method" "$url" "$body" "$timestamp") + + local curl_opts="-s" + curl_opts="$curl_opts -H 'X-Ovh-Application: ${OVH_APP_KEY}'" + curl_opts="$curl_opts -H 'X-Ovh-Consumer: ${OVH_CONSUMER_KEY}'" + curl_opts="$curl_opts -H 'X-Ovh-Timestamp: ${timestamp}'" + curl_opts="$curl_opts -H 'X-Ovh-Signature: ${signature}'" + curl_opts="$curl_opts -H 'Content-Type: application/json'" + + case "$method" in + GET) + eval curl $curl_opts "$url" + ;; + POST) + eval curl $curl_opts -X POST -d "'$body'" "$url" + ;; + PUT) + eval curl $curl_opts -X PUT -d "'$body'" "$url" + ;; + DELETE) + eval curl $curl_opts -X DELETE "$url" + ;; + esac +} + +# ---------- Telephony API ---------- + +# Get all billing accounts +ovh_get_billing_accounts() { + _ovh_request GET "/telephony" +} + +# Get SIP lines for a billing account +ovh_get_lines() { + local billing_account="$1" + _ovh_request GET "/telephony/${billing_account}/line" +} + +# Get SIP line details +ovh_get_line_info() { + local billing_account="$1" + local service_name="$2" + _ovh_request GET "/telephony/${billing_account}/line/${service_name}" +} + +# Get SIP accounts for a line +ovh_get_sip_accounts() { + local billing_account="$1" + local service_name="$2" + _ovh_request GET "/telephony/${billing_account}/line/${service_name}/sipAccounts" +} + +# Get SIP account credentials +ovh_get_sip_info() { + local billing_account="$1" + local service_name="$2" + local sip_account="$3" + + if [ -z "$sip_account" ]; then + # Get first SIP account + local accounts=$(ovh_get_sip_accounts "$billing_account" "$service_name") + sip_account=$(echo "$accounts" | jsonfilter -e '@[0]' 2>/dev/null) + fi + + _ovh_request GET "/telephony/${billing_account}/line/${service_name}/sipAccounts/${sip_account}" +} + +# Reset SIP password +ovh_reset_sip_password() { + local billing_account="$1" + local service_name="$2" + local sip_account="$3" + + _ovh_request POST "/telephony/${billing_account}/line/${service_name}/sipAccounts/${sip_account}/resetPassword" "{}" +} + +# Get voicemail settings +ovh_get_voicemail() { + local billing_account="$1" + local service_name="$2" + _ovh_request GET "/telephony/${billing_account}/voicemail/${service_name}" +} + +# Get call history +ovh_get_calls() { + local billing_account="$1" + local service_name="$2" + _ovh_request GET "/telephony/${billing_account}/service/${service_name}/eventToken" +} + +# ---------- SMS API ---------- + +# Get SMS accounts +ovh_get_sms_accounts() { + _ovh_request GET "/sms" +} + +# Get SMS account info +ovh_get_sms_info() { + local service_name="$1" + _ovh_request GET "/sms/${service_name}" +} + +# Send SMS +ovh_send_sms() { + local service_name="$1" + local sender="$2" + local receiver="$3" + local message="$4" + + local body=$(cat </dev/null) + [ -z "$billing_account" ] && { + echo "No OVH billing accounts found" >&2 + return 1 + } + uci set voip.ovh_telephony.billing_account="$billing_account" + fi + + if [ -z "$service_name" ]; then + service_name=$(ovh_get_lines "$billing_account" | jsonfilter -e '@[0]' 2>/dev/null) + [ -z "$service_name" ] && { + echo "No OVH SIP lines found" >&2 + return 1 + } + uci set voip.ovh_telephony.service_name="$service_name" + fi + + # Get SIP credentials + local sip_info=$(ovh_get_sip_info "$billing_account" "$service_name") + + local sip_username=$(echo "$sip_info" | jsonfilter -e '@.username' 2>/dev/null) + local sip_domain=$(echo "$sip_info" | jsonfilter -e '@.domain' 2>/dev/null) + + if [ -z "$sip_username" ] || [ -z "$sip_domain" ]; then + echo "Failed to get SIP credentials" >&2 + return 1 + fi + + # Reset password to get new one + local new_pass_info=$(ovh_reset_sip_password "$billing_account" "$service_name" "$sip_username") + local sip_password=$(echo "$new_pass_info" | jsonfilter -e '@.password' 2>/dev/null) + + if [ -z "$sip_password" ]; then + echo "Failed to get SIP password" >&2 + return 1 + fi + + # Save to UCI + uci set voip.sip_trunk.enabled='1' + uci set voip.sip_trunk.provider='ovh' + uci set voip.sip_trunk.host="$sip_domain" + uci set voip.sip_trunk.username="$sip_username" + uci set voip.sip_trunk.password="$sip_password" + uci commit voip + + # Generate Asterisk trunk config + local trunk_conf="/srv/lxc/voip/rootfs/etc/asterisk/pjsip_trunk.conf" + cat > "$trunk_conf" <> "/srv/lxc/voip/rootfs/etc/asterisk/pjsip.conf" + + echo "OVH trunk provisioned successfully" + echo " Username: $sip_username" + echo " Domain: $sip_domain" + echo " Password: (saved to UCI)" + + return 0 +} + +# Test OVH credentials +ovh_test_credentials() { + ovh_init || return 1 + + local result=$(ovh_get_billing_accounts) + if echo "$result" | grep -q "^\["; then + echo "OVH credentials valid" + echo "Billing accounts: $result" + return 0 + else + echo "OVH credentials invalid or error: $result" >&2 + return 1 + fi +} diff --git a/package/secubox/secubox-app-voip/files/usr/sbin/voipctl b/package/secubox/secubox-app-voip/files/usr/sbin/voipctl new file mode 100644 index 00000000..949c55e3 --- /dev/null +++ b/package/secubox/secubox-app-voip/files/usr/sbin/voipctl @@ -0,0 +1,1263 @@ +#!/bin/sh +# SecuBox VoIP Manager - LXC Debian container with Asterisk PBX + +CONFIG="voip" +LXC_NAME="voip" +LXC_PATH="/srv/lxc" +LXC_ROOTFS="$LXC_PATH/$LXC_NAME/rootfs" +LXC_CONF="$LXC_PATH/$LXC_NAME/config" +DATA_PATH_DEFAULT="/srv/voip" +OPKG_UPDATED=0 + +# Load OVH telephony API +OVH_API_LIB="/usr/lib/secubox/voip/ovh-telephony.sh" +[ -f "$OVH_API_LIB" ] && . "$OVH_API_LIB" + +usage() { + cat <<'USAGE' +Usage: voipctl + +Installation: + install Create LXC container with Asterisk PBX + uninstall Remove container (preserves data) + update Update Asterisk packages + check Run prerequisite checks + +Service: + start Start VoIP server (via init) + stop Stop VoIP server + restart Restart VoIP server + status Show container and service status + logs [N] Show last N lines of logs (default: 50) + shell Open interactive shell in container + cli Open Asterisk CLI + +Extensions: + ext add [password] Create SIP extension + ext del Delete extension + ext list List all extensions + ext passwd [password] Change password + +Trunks: + trunk add ovh Auto-provision OVH SIP trunk + trunk add manual Add manual SIP trunk + trunk del Remove trunk + trunk test Test trunk registration + trunk status Show registration status + +Calls: + call Originate call (click-to-call) + hangup Hang up active call + calls List active calls + +Voicemail: + vm list [ext] List voicemails + vm play Play voicemail (outputs path) + vm delete Delete voicemail + +Exposure: + configure-haproxy Setup HAProxy for WebRTC + emancipate Full exposure (HAProxy + SSL) + +Internal: + service-run Run container via procd + service-stop Stop container +USAGE +} + +# ---------- helpers ---------- + +require_root() { [ "$(id -u)" -eq 0 ]; } + +uci_get() { + local key="$1" + local section="${2:-main}" + uci -q get ${CONFIG}.${section}.$key +} + +uci_set() { + local key="$1" + local value="$2" + local section="${3:-main}" + uci set ${CONFIG}.${section}.$key="$value" +} + +log_info() { echo "[INFO] $*"; logger -t voipctl "$*"; } +log_warn() { echo "[WARN] $*"; logger -t voipctl -p warning "$*"; } +log_error() { echo "[ERROR] $*" >&2; logger -t voipctl -p err "$*"; } + +ensure_dir() { [ -d "$1" ] || mkdir -p "$1"; } + +ensure_packages() { + for pkg in "$@"; do + if ! opkg status "$pkg" 2>/dev/null | grep -q "Status:.*installed"; then + if [ "$OPKG_UPDATED" -eq 0 ]; then + opkg update || return 1 + OPKG_UPDATED=1 + fi + opkg install "$pkg" || return 1 + fi + done +} + +defaults() { + data_path="$(uci_get data_path || echo $DATA_PATH_DEFAULT)" + memory_limit="$(uci_get memory_limit || echo 512)" + sip_port="$(uci_get sip_port asterisk || echo 5060)" + rtp_start="$(uci_get rtp_start asterisk || echo 10000)" + rtp_end="$(uci_get rtp_end asterisk || echo 20000)" + ari_port="$(uci_get ari_port asterisk || echo 8089)" + ami_port="$(uci_get ami_port asterisk || echo 5038)" +} + +detect_arch() { + case "$(uname -m)" in + aarch64) echo "aarch64" ;; + armv7l) echo "armv7" ;; + x86_64) echo "x86_64" ;; + *) echo "x86_64" ;; + esac +} + +generate_password() { + head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 16 +} + +# ---------- LXC helpers ---------- + +lxc_running() { + lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING" +} + +lxc_exists() { + [ -f "$LXC_CONF" ] && [ -d "$LXC_ROOTFS" ] +} + +lxc_exec() { + lxc-attach -n "$LXC_NAME" -- "$@" +} + +lxc_stop() { + if lxc_running; then + lxc-stop -n "$LXC_NAME" -k 2>/dev/null || true + sleep 2 + fi +} + +# ---------- Asterisk helpers ---------- + +asterisk_cli() { + lxc_exec asterisk -rx "$*" +} + +# ---------- rootfs creation ---------- + +lxc_create_rootfs() { + local arch=$(detect_arch) + + local debian_arch + case "$arch" in + aarch64) debian_arch="arm64" ;; + armv7) debian_arch="armhf" ;; + x86_64) debian_arch="amd64" ;; + *) debian_arch="amd64" ;; + esac + + ensure_dir "$LXC_ROOTFS" + + local rootfs_url="https://images.linuxcontainers.org/images/debian/bookworm/${debian_arch}/default/" + log_info "Downloading Debian bookworm rootfs for ${debian_arch}..." + + local latest_path + latest_path=$(wget -q -O - "$rootfs_url" 2>/dev/null | grep -oE '[0-9]{8}_[0-9]{2}:[0-9]{2}' | tail -1) + if [ -z "$latest_path" ]; then + log_error "Failed to find latest Debian rootfs build" + return 1 + fi + + local tarball="/tmp/debian-voip.tar.xz" + local tarball_url="${rootfs_url}${latest_path}/rootfs.tar.xz" + wget -q -O "$tarball" "$tarball_url" || { + log_error "Failed to download Debian rootfs from $tarball_url" + return 1 + } + + tar -xJf "$tarball" -C "$LXC_ROOTFS" || { + log_error "Failed to extract Debian rootfs" + return 1 + } + rm -f "$tarball" + + # DNS + cp /etc/resolv.conf "$LXC_ROOTFS/etc/resolv.conf" 2>/dev/null || \ + echo "nameserver 8.8.8.8" > "$LXC_ROOTFS/etc/resolv.conf" + + # Create minimal /dev for chroot operations + mkdir -p "$LXC_ROOTFS/dev" + [ -c "$LXC_ROOTFS/dev/null" ] || mknod -m 666 "$LXC_ROOTFS/dev/null" c 1 3 2>/dev/null + [ -c "$LXC_ROOTFS/dev/zero" ] || mknod -m 666 "$LXC_ROOTFS/dev/zero" c 1 5 2>/dev/null + [ -c "$LXC_ROOTFS/dev/random" ] || mknod -m 666 "$LXC_ROOTFS/dev/random" c 1 8 2>/dev/null + [ -c "$LXC_ROOTFS/dev/urandom" ] || mknod -m 666 "$LXC_ROOTFS/dev/urandom" c 1 9 2>/dev/null + + # Configure apt sources + cat > "$LXC_ROOTFS/etc/apt/sources.list" <<'SOURCES' +deb http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware +deb http://deb.debian.org/debian bookworm-updates main contrib non-free non-free-firmware +deb http://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware +SOURCES + + # Install Asterisk PBX + log_info "Installing Asterisk PBX..." + chroot "$LXC_ROOTFS" /bin/sh -c " + export DEBIAN_FRONTEND=noninteractive + apt-get update && \ + apt-get install -y --no-install-recommends \ + asterisk \ + asterisk-core-sounds-en \ + asterisk-core-sounds-fr \ + asterisk-moh-opsound-wav \ + asterisk-modules \ + ca-certificates \ + curl \ + procps + " || { + log_error "Failed to install Asterisk" + return 1 + } + + # Create directories + mkdir -p "$LXC_ROOTFS/var/spool/asterisk/voicemail" + mkdir -p "$LXC_ROOTFS/var/log/asterisk" + mkdir -p "$LXC_ROOTFS/etc/asterisk" + mkdir -p "$LXC_ROOTFS/srv/voip/sounds" + + # Create startup script + create_startup_script + + # Clean up apt cache + chroot "$LXC_ROOTFS" /bin/sh -c " + apt-get clean + rm -rf /var/lib/apt/lists/* + " + + log_info "Rootfs created successfully" +} + +create_startup_script() { + cat > "$LXC_ROOTFS/opt/start-voip.sh" <<'STARTUP' +#!/bin/bash + +export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + +# Get config from environment +SIP_PORT="${VOIP_SIP_PORT:-5060}" +RTP_START="${VOIP_RTP_START:-10000}" +RTP_END="${VOIP_RTP_END:-20000}" +ARI_PORT="${VOIP_ARI_PORT:-8089}" +ARI_USER="${VOIP_ARI_USER:-admin}" +ARI_PASSWORD="${VOIP_ARI_PASSWORD:-}" +AMI_PORT="${VOIP_AMI_PORT:-5038}" +AMI_USER="${VOIP_AMI_USER:-admin}" +AMI_SECRET="${VOIP_AMI_SECRET:-}" + +# Generate Asterisk config if not exists +if [ ! -f /etc/asterisk/.configured ]; then + echo "[VOIP] Generating Asterisk configuration..." + + # pjsip.conf - SIP stack + cat > /etc/asterisk/pjsip.conf < /etc/asterisk/extensions.conf < _1XX,1,NoOp(Dialing extension \${EXTEN}) + same => n,Dial(PJSIP/\${EXTEN},30,tT) + same => n,VoiceMail(\${EXTEN}@default,u) + same => n,Hangup() + +; Voicemail access +exten => *98,1,VoiceMailMain(\${CALLERID(num)}@default) + same => n,Hangup() + +exten => *97,1,VoiceMailMain(@default) + same => n,Hangup() + +[from-trunk] +; Incoming calls from trunk +exten => _X.,1,NoOp(Incoming call to \${EXTEN}) + same => n,Goto(internal,100,1) ; Ring extension 100 by default + same => n,Hangup() + +[outbound] +; Outbound calls via trunk +exten => _0X.,1,NoOp(Outbound call to \${EXTEN:1}) + same => n,Set(CALLERID(num)=\${TRUNK_CALLER_ID}) + same => n,Dial(PJSIP/\${EXTEN:1}@ovh-endpoint,60,tT) + same => n,Hangup() + +exten => _+X.,1,NoOp(Outbound call to \${EXTEN}) + same => n,Set(CALLERID(num)=\${TRUNK_CALLER_ID}) + same => n,Dial(PJSIP/\${EXTEN}@ovh-endpoint,60,tT) + same => n,Hangup() +DIALPLAN + + # voicemail.conf + cat > /etc/asterisk/voicemail.conf < /etc/asterisk/rtp.conf < /etc/asterisk/http.conf < /etc/asterisk/ari.conf < /etc/asterisk/manager.conf < /etc/asterisk/modules.conf < chan_sip.so +noload => res_hep.so +noload => res_hep_pjsip.so +noload => res_hep_rtcp.so +load => res_pjsip.so +load => res_pjsip_session.so +MODULES + + touch /etc/asterisk/.configured + echo "[VOIP] Configuration generated" +fi + +# Ensure proper permissions +chown -R asterisk:asterisk /var/spool/asterisk +chown -R asterisk:asterisk /var/log/asterisk +chown -R asterisk:asterisk /etc/asterisk +chown -R asterisk:asterisk /srv/voip 2>/dev/null + +echo "[VOIP] Starting Asterisk PBX..." + +# Run Asterisk in foreground +exec /usr/sbin/asterisk -f -U asterisk -G asterisk +STARTUP + + chmod +x "$LXC_ROOTFS/opt/start-voip.sh" +} + +lxc_create_config() { + defaults + local mem_bytes=$((memory_limit * 1024 * 1024)) + + ensure_dir "$LXC_PATH/$LXC_NAME" + ensure_dir "$data_path" + ensure_dir "$data_path/sounds" + ensure_dir "$data_path/voicemail" + ensure_dir "$data_path/config" + + # Generate passwords if not set + local ari_pass=$(uci_get ari_password asterisk) + [ -z "$ari_pass" ] && { + ari_pass=$(generate_password) + uci_set ari_password "$ari_pass" asterisk + } + + local ami_secret=$(uci_get ami_secret asterisk) + [ -z "$ami_secret" ] && { + ami_secret=$(generate_password) + uci_set ami_secret "$ami_secret" asterisk + } + uci commit "$CONFIG" + + cat > "$LXC_CONF" </dev/null + /etc/init.d/voip disable 2>/dev/null + lxc_stop + + rm -rf "$LXC_ROOTFS" "$LXC_CONF" + + uci_set enabled '0' + uci commit "$CONFIG" + + defaults + log_info "Container removed. Data preserved in $data_path" +} + +cmd_update() { + require_root || { log_error "Must run as root"; return 1; } + + log_info "Updating Asterisk..." + + if lxc_running; then + lxc_exec apt-get update + lxc_exec apt-get upgrade -y asterisk asterisk-modules + lxc_exec asterisk -rx "core restart now" + log_info "Asterisk updated successfully" + else + log_error "Container not running" + return 1 + fi +} + +cmd_check() { + echo "VoIP Prerequisites Check" + echo "=========================" + + if command -v lxc-start >/dev/null 2>&1; then + echo "[OK] LXC installed" + else + echo "[FAIL] LXC not installed" + fi + + if lxc_exists; then + echo "[OK] Container exists" + else + echo "[--] Container not created" + fi + + if lxc_running; then + echo "[OK] Container running" + else + echo "[--] Container not running" + fi + + defaults + for port in $sip_port $ari_port $ami_port; do + if netstat -tln 2>/dev/null | grep -q ":${port} " || \ + grep -q ":$(printf '%04X' $port) " /proc/net/tcp 2>/dev/null; then + echo "[OK] Port $port listening" + else + echo "[--] Port $port not listening" + fi + done + + if lxc_running; then + if lxc_exec pgrep asterisk >/dev/null 2>&1; then + echo "[OK] Asterisk process running" + else + echo "[FAIL] Asterisk process not running" + fi + fi +} + +cmd_status() { + defaults + + if [ "$1" = "--json" ]; then + local running=0 + local asterisk_proc=0 + local trunk_registered=0 + local active_calls=0 + local extensions=0 + + lxc_running && running=1 + if [ "$running" = "1" ]; then + lxc_exec pgrep asterisk >/dev/null 2>&1 && asterisk_proc=1 + asterisk_cli "pjsip show registrations" 2>/dev/null | grep -q "Registered" && trunk_registered=1 + active_calls=$(asterisk_cli "core show channels" 2>/dev/null | grep -oE "^[0-9]+ active" | cut -d' ' -f1 || echo 0) + extensions=$(asterisk_cli "pjsip show endpoints" 2>/dev/null | grep -c "^[0-9]" || echo 0) + fi + + cat </dev/null 2>&1 && echo " Asterisk: UP" || echo " Asterisk: DOWN" + + echo "" + echo "Trunk Status:" + asterisk_cli "pjsip show registrations" 2>/dev/null | grep -E "State|Contact" | head -5 | sed 's/^/ /' + + echo "" + echo "Active Calls:" + asterisk_cli "core show channels concise" 2>/dev/null | head -5 | sed 's/^/ /' + else + echo "State: STOPPED" + fi +} + +cmd_logs() { + local lines="${1:-50}" + + if lxc_running; then + echo "=== Asterisk logs ===" + lxc_exec tail -n "$lines" /var/log/asterisk/messages 2>/dev/null || \ + echo "No Asterisk logs found" + else + echo "Container not running" + fi +} + +cmd_shell() { + if lxc_running; then + lxc_exec /bin/bash || lxc_exec /bin/sh + else + log_error "Container not running" + return 1 + fi +} + +cmd_cli() { + if lxc_running; then + lxc_exec asterisk -rvvv + else + log_error "Container not running" + return 1 + fi +} + +cmd_start() { + require_root || { log_error "Must run as root"; return 1; } + /etc/init.d/voip start +} + +cmd_stop() { + require_root || { log_error "Must run as root"; return 1; } + /etc/init.d/voip stop +} + +cmd_restart() { + require_root || { log_error "Must run as root"; return 1; } + /etc/init.d/voip restart +} + +# ---------- extension management ---------- + +cmd_ext() { + local subcmd="$1" + shift + + case "$subcmd" in + add) + cmd_ext_add "$@" + ;; + del|delete) + cmd_ext_del "$@" + ;; + passwd|password) + cmd_ext_passwd "$@" + ;; + list) + cmd_ext_list + ;; + *) + echo "Usage: voipctl ext " + return 1 + ;; + esac +} + +cmd_ext_add() { + local ext="$1" + local name="$2" + local password="$3" + + [ -z "$ext" ] || [ -z "$name" ] && { + echo "Usage: voipctl ext add [password]" + return 1 + } + + lxc_running || { log_error "Container not running"; return 1; } + + [ -z "$password" ] && password=$(generate_password) + + # Add to pjsip.conf + cat >> "$LXC_ROOTFS/etc/asterisk/pjsip.conf" < + +[$ext-auth](auth-template) +username=$ext +password=$password + +[$ext](aor-template) +EOF + + # Add voicemail + echo "$ext => $password,$name,," >> "$LXC_ROOTFS/etc/asterisk/voicemail.conf" + + # Save to UCI + uci set voip.ext_${ext}=extension + uci set voip.ext_${ext}.name="$name" + uci set voip.ext_${ext}.secret="$password" + uci set voip.ext_${ext}.voicemail='1' + uci commit voip + + # Reload Asterisk + asterisk_cli "pjsip reload" + asterisk_cli "voicemail reload" + + log_info "Extension created: $ext ($name)" + log_info "Password: $password" + log_info "" + log_info "SIP Settings:" + log_info " Server: $(uci -q get network.lan.ipaddr || echo '192.168.255.1')" + log_info " Username: $ext" + log_info " Password: $password" +} + +cmd_ext_del() { + local ext="$1" + + [ -z "$ext" ] && { + echo "Usage: voipctl ext del " + return 1 + } + + lxc_running || { log_error "Container not running"; return 1; } + + # Remove from pjsip.conf (simplified - removes the section) + sed -i "/^\[$ext\]/,/^$/d" "$LXC_ROOTFS/etc/asterisk/pjsip.conf" + sed -i "/^\[$ext-auth\]/,/^$/d" "$LXC_ROOTFS/etc/asterisk/pjsip.conf" + + # Remove from voicemail.conf + sed -i "/^$ext =>/d" "$LXC_ROOTFS/etc/asterisk/voicemail.conf" + + # Remove UCI + uci delete voip.ext_${ext} 2>/dev/null + uci commit voip + + asterisk_cli "pjsip reload" + + log_info "Extension deleted: $ext" +} + +cmd_ext_passwd() { + local ext="$1" + local password="$2" + + [ -z "$ext" ] && { + echo "Usage: voipctl ext passwd [password]" + return 1 + } + + lxc_running || { log_error "Container not running"; return 1; } + + [ -z "$password" ] && password=$(generate_password) + + # Update in pjsip.conf + sed -i "/^\[$ext-auth\]/,/^$/{s/password=.*/password=$password/}" "$LXC_ROOTFS/etc/asterisk/pjsip.conf" + + # Update UCI + uci set voip.ext_${ext}.secret="$password" + uci commit voip + + asterisk_cli "pjsip reload" + + log_info "Password changed for extension $ext" + log_info "New password: $password" +} + +cmd_ext_list() { + lxc_running || { log_error "Container not running"; return 1; } + + echo "Extensions:" + echo "===========" + asterisk_cli "pjsip show endpoints" 2>/dev/null | grep -E "^[0-9]|Endpoint:" | head -20 +} + +# ---------- trunk management ---------- + +cmd_trunk() { + local subcmd="$1" + shift + + case "$subcmd" in + add) + cmd_trunk_add "$@" + ;; + del|delete) + cmd_trunk_del + ;; + test) + cmd_trunk_test + ;; + status) + cmd_trunk_status + ;; + *) + echo "Usage: voipctl trunk " + return 1 + ;; + esac +} + +cmd_trunk_add() { + local provider="$1" + + case "$provider" in + ovh) + cmd_trunk_add_ovh + ;; + manual) + cmd_trunk_add_manual + ;; + *) + echo "Usage: voipctl trunk add " + return 1 + ;; + esac +} + +cmd_trunk_add_ovh() { + log_info "Adding OVH SIP trunk..." + + # Check OVH credentials + local app_key=$(uci_get app_key ovh_telephony) + local app_secret=$(uci_get app_secret ovh_telephony) + local consumer_key=$(uci_get consumer_key ovh_telephony) + + if [ -z "$app_key" ] || [ -z "$app_secret" ] || [ -z "$consumer_key" ]; then + log_error "OVH API credentials not configured" + log_info "Configure via: uci set voip.ovh_telephony.app_key=..." + log_info "Generate at: https://eu.api.ovh.com/createToken/" + return 1 + fi + + # Use OVH API to get SIP credentials + if [ -f "$OVH_API_LIB" ]; then + ovh_init + local accounts=$(ovh_get_billing_accounts) + log_info "Available billing accounts: $accounts" + + # For now, use first account (interactive selection can be added) + local billing_account=$(echo "$accounts" | jsonfilter -e '@[0]' 2>/dev/null) + [ -z "$billing_account" ] && { + log_error "No billing accounts found" + return 1 + } + + local lines=$(ovh_get_lines "$billing_account") + log_info "Available SIP lines: $lines" + + local service_name=$(echo "$lines" | jsonfilter -e '@[0]' 2>/dev/null) + [ -z "$service_name" ] && { + log_error "No SIP lines found" + return 1 + } + + local sip_info=$(ovh_get_sip_info "$billing_account" "$service_name") + log_info "SIP Info: $sip_info" + + # Extract credentials and configure + # (Implementation depends on OVH API response format) + else + log_warn "OVH API library not found, using manual configuration" + cmd_trunk_add_manual + fi +} + +cmd_trunk_add_manual() { + log_info "Manual SIP trunk configuration" + log_info "Set trunk parameters via UCI:" + log_info " uci set voip.sip_trunk.host='sip.provider.com'" + log_info " uci set voip.sip_trunk.username='your_username'" + log_info " uci set voip.sip_trunk.password='your_password'" + log_info " uci commit voip" + log_info " voipctl restart" +} + +cmd_trunk_del() { + log_info "Removing SIP trunk..." + + uci set voip.sip_trunk.enabled='0' + uci set voip.sip_trunk.username='' + uci set voip.sip_trunk.password='' + uci commit voip + + # Remove from pjsip.conf + sed -i '/\[ovh-/,/^$/d' "$LXC_ROOTFS/etc/asterisk/pjsip.conf" + + asterisk_cli "pjsip reload" + + log_info "Trunk removed" +} + +cmd_trunk_test() { + lxc_running || { log_error "Container not running"; return 1; } + + log_info "Testing trunk registration..." + asterisk_cli "pjsip show registrations" + asterisk_cli "pjsip qualify ovh-endpoint" +} + +cmd_trunk_status() { + lxc_running || { log_error "Container not running"; return 1; } + + echo "Trunk Registration Status:" + echo "==========================" + asterisk_cli "pjsip show registrations" +} + +# ---------- call management ---------- + +cmd_call() { + local from="$1" + local to="$2" + + [ -z "$from" ] || [ -z "$to" ] && { + echo "Usage: voipctl call " + return 1 + } + + lxc_running || { log_error "Container not running"; return 1; } + + log_info "Originating call: $from -> $to" + + # Originate call via AMI + local result=$(asterisk_cli "channel originate PJSIP/$from extension $to@outbound") + echo "$result" +} + +cmd_hangup() { + local channel="$1" + + [ -z "$channel" ] && { + echo "Usage: voipctl hangup " + return 1 + } + + lxc_running || { log_error "Container not running"; return 1; } + + asterisk_cli "channel hangup $channel" +} + +cmd_calls() { + lxc_running || { log_error "Container not running"; return 1; } + + echo "Active Calls:" + echo "=============" + asterisk_cli "core show channels" +} + +# ---------- voicemail management ---------- + +cmd_vm() { + local subcmd="$1" + shift + + case "$subcmd" in + list) + cmd_vm_list "$@" + ;; + play) + cmd_vm_play "$@" + ;; + delete) + cmd_vm_delete "$@" + ;; + *) + echo "Usage: voipctl vm " + return 1 + ;; + esac +} + +cmd_vm_list() { + local ext="$1" + + lxc_running || { log_error "Container not running"; return 1; } + + if [ -n "$ext" ]; then + echo "Voicemails for extension $ext:" + lxc_exec ls -la /var/spool/asterisk/voicemail/default/$ext/INBOX/ 2>/dev/null || echo " None" + else + echo "All voicemails:" + lxc_exec find /var/spool/asterisk/voicemail -name "*.wav" 2>/dev/null | head -20 + fi +} + +cmd_vm_play() { + local ext="$1" + local id="$2" + + [ -z "$ext" ] || [ -z "$id" ] && { + echo "Usage: voipctl vm play " + return 1 + } + + local path="/var/spool/asterisk/voicemail/default/$ext/INBOX/msg${id}.wav" + echo "$path" +} + +cmd_vm_delete() { + local ext="$1" + local id="$2" + + [ -z "$ext" ] || [ -z "$id" ] && { + echo "Usage: voipctl vm delete " + return 1 + } + + lxc_running || { log_error "Container not running"; return 1; } + + local path="/var/spool/asterisk/voicemail/default/$ext/INBOX/msg${id}.*" + lxc_exec rm -f $path + + log_info "Voicemail deleted: $ext/$id" +} + +# ---------- HAProxy integration ---------- + +cmd_configure_haproxy() { + require_root || { log_error "Must run as root"; return 1; } + defaults + + local domain=$(uci_get domain ssl) + [ -z "$domain" ] && { + log_error "Domain not configured. Set: uci set voip.ssl.domain=voip.example.com" + return 1 + } + + log_info "Configuring HAProxy for WebRTC..." + + # Create backend for WebRTC/WSS + local backend_name="voip_wss" + + uci set haproxy.${backend_name}=backend + uci set haproxy.${backend_name}.name="$backend_name" + uci set haproxy.${backend_name}.mode='http' + uci set haproxy.${backend_name}.enabled='1' + uci set haproxy.${backend_name}.timeout_server='3600s' + uci set haproxy.${backend_name}.timeout_tunnel='3600s' + uci set haproxy.${backend_name}.server="voip 127.0.0.1:${ari_port} check" + + # Create vhost + local vhost_name=$(echo "$domain" | tr '.-' '_') + uci set haproxy.${vhost_name}=vhost + uci set haproxy.${vhost_name}.domain="$domain" + uci set haproxy.${vhost_name}.backend="$backend_name" + uci set haproxy.${vhost_name}.ssl='1' + uci set haproxy.${vhost_name}.ssl_redirect='1' + uci set haproxy.${vhost_name}.acme='1' + uci set haproxy.${vhost_name}.enabled='1' + + uci commit haproxy + + uci_set enabled '1' ssl + uci_set domain "$domain" ssl + uci commit "$CONFIG" + + if command -v haproxyctl >/dev/null 2>&1; then + haproxyctl generate + /etc/init.d/haproxy reload + fi + + log_info "HAProxy configured for $domain" + log_info "WebRTC WSS: wss://$domain/ws" +} + +cmd_emancipate() { + local domain="$1" + + [ -z "$domain" ] && { + echo "Usage: voipctl emancipate " + return 1 + } + + require_root || { log_error "Must run as root"; return 1; } + + log_info "Emancipating VoIP at $domain..." + + uci_set domain "$domain" ssl + uci commit "$CONFIG" + + cmd_configure_haproxy + + log_info "" + log_info "==============================================" + log_info " VoIP Emancipated!" + log_info "==============================================" + log_info "" + log_info " Domain: $domain" + log_info " SIP: $domain:$sip_port" + log_info " WebRTC: wss://$domain/ws" + log_info "" +} + +# ---------- service management ---------- + +cmd_service_run() { + require_root || exit 1 + defaults + + lxc_exists || { log_error "Container not found. Run: voipctl install"; exit 1; } + + log_info "Starting VoIP container..." + + exec lxc-start -n "$LXC_NAME" -F -f "$LXC_CONF" +} + +cmd_service_stop() { + log_info "Stopping VoIP container..." + lxc_stop +} + +# ---------- main ---------- + +case "$1" in + install) cmd_install ;; + uninstall) cmd_uninstall ;; + update) cmd_update ;; + check) cmd_check ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_restart ;; + status) shift; cmd_status "$@" ;; + logs) shift; cmd_logs "$@" ;; + shell) cmd_shell ;; + cli) cmd_cli ;; + ext) shift; cmd_ext "$@" ;; + trunk) shift; cmd_trunk "$@" ;; + call) shift; cmd_call "$@" ;; + hangup) shift; cmd_hangup "$@" ;; + calls) cmd_calls ;; + vm) shift; cmd_vm "$@" ;; + configure-haproxy) cmd_configure_haproxy ;; + emancipate) shift; cmd_emancipate "$@" ;; + service-run) cmd_service_run ;; + service-stop) cmd_service_stop ;; + *) usage; exit 1 ;; +esac