From 8a4d7a0504dcbb32aa70874068be316e62e502fa Mon Sep 17 00:00:00 2001 From: sorcier Date: Tue, 3 Nov 2026 19:07:19 +0100 Subject: [PATCH] feat: add support for inbound trunk registration (Receive mode) for FXO gateways like Grandstream HT813 and analog Livebox integration --- public/api.php | 37 ++++++++++--- public/assets/app.js | 115 ++++++++++++++++++++++++++++++++++++++-- public/index.php | 78 +++++++++++++++++++++++++-- src/ConfigGenerator.php | 85 ++++++++++++++++++++++------- src/Database.php | 7 ++- 5 files changed, 285 insertions(+), 37 deletions(-) diff --git a/public/api.php b/public/api.php index 1edabb4..91ecdc6 100644 --- a/public/api.php +++ b/public/api.php @@ -1086,7 +1086,15 @@ try { $username = trim((string)($payload['username'] ?? '')); $auth_username = trim((string)($payload['auth_username'] ?? '')); $secret = trim((string)($payload['secret'] ?? '')); - $register = ($auth_mode === 'ip') ? 0 : (int)!empty($payload['register']); + + $rawRegMode = strtolower(trim((string)($payload['registration_mode'] ?? ''))); + if (in_array($rawRegMode, ['send', 'receive', 'none'], true)) { + $registration_mode = $rawRegMode; + } else { + $registration_mode = (!empty($payload['register']) && $auth_mode !== 'ip') ? 'send' : 'none'; + } + $register = ($registration_mode === 'send') ? 1 : 0; + $server_uri = trim((string)($payload['server_uri'] ?? '')); $client_uri = trim((string)($payload['client_uri'] ?? '')); $contact_user = trim((string)($payload['contact_user'] ?? '')); @@ -1130,8 +1138,17 @@ try { $cleanDidRoutes = ConfigGenerator::normalizeInboundRoutes(json_encode($didRoutesRaw)); $did_routes_json = json_encode($cleanDidRoutes, JSON_UNESCAPED_UNICODE); - if ($name === '' || $host === '') { - throw new InvalidArgumentException("Le nom du Trunk et l'hôte SIP opérateur sont requis."); + if ($name === '') { + throw new InvalidArgumentException("Le nom du Trunk est requis."); + } + if ($registration_mode === 'receive') { + if ($username === '' || $secret === '') { + throw new InvalidArgumentException("En mode Réception (ex: passerelle Grandstream HT813 / Livebox), l'Identifiant (Username) et le Mot de passe SIP sont requis pour authentifier la passerelle."); + } + } else { + if ($host === '') { + throw new InvalidArgumentException("L'hôte SIP opérateur (Serveur SIP) est requis."); + } } $params = [ @@ -1144,6 +1161,7 @@ try { ':username' => $username, ':auth_username' => $auth_username, ':secret' => $secret, + ':registration_mode' => $registration_mode, ':register' => $register, ':server_uri' => $server_uri, ':client_uri' => $client_uri, @@ -1192,7 +1210,8 @@ try { UPDATE trunks SET name = :name, host = :host, port = :port, outbound_proxy = :outbound_proxy, transport = :transport, auth_mode = :auth_mode, username = :username, - auth_username = :auth_username, secret = :secret, register = :register, + auth_username = :auth_username, secret = :secret, + registration_mode = :registration_mode, register = :register, server_uri = :server_uri, client_uri = :client_uri, contact_user = :contact_user, expiration = :expiration, retry_interval = :retry_interval, max_retries = :max_retries, forbidden_retry_interval = :forbidden_retry_interval, @@ -1217,7 +1236,7 @@ try { $stmt = $db->prepare(' INSERT INTO trunks ( name, host, port, outbound_proxy, transport, auth_mode, username, auth_username, secret, - register, server_uri, client_uri, contact_user, expiration, retry_interval, + registration_mode, register, server_uri, client_uri, contact_user, expiration, retry_interval, max_retries, forbidden_retry_interval, fatal_retry_interval, line_support, from_user, from_domain, outbound_callerid, send_pai, send_rpid, send_diversion, trust_id_inbound, codecs, dtmf_mode, media_encryption, nat_symmetric, direct_media, @@ -1226,7 +1245,7 @@ try { outbound_prefix, custom_endpoint_params, custom_reg_params, custom_aor_params, moh_sound_id, enabled ) VALUES ( :name, :host, :port, :outbound_proxy, :transport, :auth_mode, :username, :auth_username, :secret, - :register, :server_uri, :client_uri, :contact_user, :expiration, :retry_interval, + :registration_mode, :register, :server_uri, :client_uri, :contact_user, :expiration, :retry_interval, :max_retries, :forbidden_retry_interval, :fatal_retry_interval, :line_support, :from_user, :from_domain, :outbound_callerid, :send_pai, :send_rpid, :send_diversion, :trust_id_inbound, :codecs, :dtmf_mode, :media_encryption, :nat_symmetric, :direct_media, @@ -1237,7 +1256,8 @@ try { ON CONFLICT(name) DO UPDATE SET host = excluded.host, port = excluded.port, outbound_proxy = excluded.outbound_proxy, transport = excluded.transport, auth_mode = excluded.auth_mode, username = excluded.username, - auth_username = excluded.auth_username, secret = excluded.secret, register = excluded.register, + auth_username = excluded.auth_username, secret = excluded.secret, + registration_mode = excluded.registration_mode, register = excluded.register, server_uri = excluded.server_uri, client_uri = excluded.client_uri, contact_user = excluded.contact_user, expiration = excluded.expiration, retry_interval = excluded.retry_interval, max_retries = excluded.max_retries, forbidden_retry_interval = excluded.forbidden_retry_interval, @@ -1259,9 +1279,10 @@ try { $stmt->execute($params); } + $displayHost = $host !== '' ? "{$host}:{$port}" : "Passerelle locale / HT813"; echo json_encode([ 'success' => true, - 'message' => "Trunk SIP « {$name} » ({$host}:{$port}) sauvegardé avec tous ses paramètres PJSIP.", + 'message' => "Trunk SIP « {$name} » ({$displayHost}) sauvegardé avec tous ses paramètres PJSIP.", ]); break; diff --git a/public/assets/app.js b/public/assets/app.js index 9257e6c..a3c232d 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -1579,14 +1579,25 @@ function renderTrunksTable() { tbody.innerHTML = rows.map(t => { const slug = `trunk-${slugifyTrunkName(t.name)}`; const regInfo = regs[slug]; + const regMode = t.registration_mode || (Number(t.register) === 1 ? 'send' : 'none'); const isIpAuth = t.auth_mode === 'ip'; - const wantsReg = !isIpAuth && Number(t.register) === 1; let statusBadge = ''; if (Number(t.enabled ?? 1) === 0) { statusBadge = `Désactivé`; - } else if (isIpAuth || !wantsReg) { - statusBadge = `Peering / Sans Reg.`; + } else if (regMode === 'receive') { + const contacts = appState.metrics?.asterisk?.contacts || []; + const isOnline = contacts.some(c => + (c.extension === slug || c.extension === t.username || (t.username && c.uri && c.uri.includes(t.username))) + && String(c.status || '').toLowerCase().includes('avail') + ); + if (isOnline) { + statusBadge = `Passerelle En ligne`; + } else { + statusBadge = `En attente passerelle (Receive)`; + } + } else if (isIpAuth || regMode === 'none') { + statusBadge = `Peering IP direct`; } else if (regInfo && /Registered/i.test(regInfo.status)) { statusBadge = `Registered`; } else if (regInfo) { @@ -1618,7 +1629,9 @@ function renderTrunksTable() { ${statusBadge} -
${escapeHtml(t.host)}:${Number(t.port)}
+
+ ${t.host ? escapeHtml(t.host) + ':' + Number(t.port || 5060) : 'Passerelle locale (Dynamique)'} +
${t.outbound_proxy ? `
Proxy: ${escapeHtml(t.outbound_proxy)}
` : ''} @@ -2085,6 +2098,57 @@ function parseFreePbxConfig() { } } +function onTrunkRegistrationModeChange(mode) { + const form = document.getElementById('form-trunk'); + if (!form) return; + + const hostInput = form.elements['host']; + const hostLabel = document.getElementById('trunk-host-label'); + const hostHint = document.getElementById('trunk-host-hint'); + const receiveGuide = document.getElementById('trunk-receive-guide'); + const regNotice = document.getElementById('trunk-reg-outbound-notice'); + + if (mode === 'receive') { + if (hostInput) { + hostInput.required = false; + hostInput.placeholder = 'Optionnel (vide = IP découverte dynamiquement au REGISTER du HT813)'; + } + if (hostLabel) { + hostLabel.innerHTML = 'Serveur SIP / IP Passerelle (Optionnel en mode Réception)'; + } + hostHint?.classList.remove('hidden'); + receiveGuide?.classList.remove('hidden'); + regNotice?.classList.remove('hidden'); + if (form.elements['register']) form.elements['register'].checked = false; + if (form.elements['line_support']) form.elements['line_support'].checked = false; + } else if (mode === 'none') { + if (hostInput) { + hostInput.required = true; + hostInput.placeholder = 'ex: 192.168.1.250 ou sbc.direct.net'; + } + if (hostLabel) { + hostLabel.innerHTML = 'Serveur SIP / SBC / Passerelle * (Host / IP)'; + } + hostHint?.classList.add('hidden'); + receiveGuide?.classList.add('hidden'); + regNotice?.classList.remove('hidden'); + if (form.elements['register']) form.elements['register'].checked = false; + } else { + // mode === 'send' + if (hostInput) { + hostInput.required = true; + hostInput.placeholder = 'ex: sbc.operateur.net ou siptrunk.ovh.net'; + } + if (hostLabel) { + hostLabel.innerHTML = 'Serveur SIP / SBC / Passerelle * (Host / IP)'; + } + hostHint?.classList.add('hidden'); + receiveGuide?.classList.add('hidden'); + regNotice?.classList.add('hidden'); + if (form.elements['register']) form.elements['register'].checked = true; + } +} + function applyTrunkArchitecture(arch) { const form = document.getElementById('form-trunk'); if (!form) return; @@ -2095,17 +2159,31 @@ function applyTrunkArchitecture(arch) { port: 5060, transport: 'udp', auth_mode: 'userpass', + registration_mode: 'send', register: true, line_support: true, media_encryption: 'no', nat_symmetric: true, qualify_frequency: 60 }, + gateway_receive: { + label: 'Passerelle FXO / Analogique (Grandstream HT813, Livebox, ATA)', + port: 5060, + transport: 'udp', + auth_mode: 'userpass', + registration_mode: 'receive', + register: false, + line_support: false, + media_encryption: 'no', + nat_symmetric: true, + qualify_frequency: 60 + }, ip_peering: { label: 'Peering IP / SBC / Passerelle (Sans REGISTER)', port: 5060, transport: 'udp', auth_mode: 'ip', + registration_mode: 'none', register: false, line_support: false, media_encryption: 'no', @@ -2117,6 +2195,7 @@ function applyTrunkArchitecture(arch) { port: 5061, transport: 'tls', auth_mode: 'userpass', + registration_mode: 'send', register: true, line_support: true, media_encryption: 'sdes', @@ -2134,6 +2213,13 @@ function applyTrunkArchitecture(arch) { form.querySelectorAll('input[name="auth_mode"]').forEach(r => { r.checked = (r.value === a.auth_mode); }); toggleTrunkAuthFields(a.auth_mode); + if (a.registration_mode) { + form.querySelectorAll('input[name="registration_mode"]').forEach(r => { + r.checked = (r.value === a.registration_mode); + }); + onTrunkRegistrationModeChange(a.registration_mode); + } + form.elements['register'].checked = a.register; form.elements['line_support'].checked = a.line_support; form.elements['media_encryption'].value = a.media_encryption; @@ -2152,11 +2238,18 @@ function openTrunkModal(trunk = null) { form.elements['id'].value = trunk ? trunk.id : ''; form.elements['name'].value = trunk ? trunk.name : ''; - form.elements['host'].value = trunk ? trunk.host : ''; + form.elements['host'].value = trunk ? (trunk.host ?? '') : ''; form.elements['port'].value = trunk ? (trunk.port ?? 5060) : 5060; form.elements['outbound_proxy'].value = trunk ? (trunk.outbound_proxy ?? '') : ''; form.elements['transport'].value = trunk ? (trunk.transport ?? 'udp') : 'udp'; + const regMode = trunk ? (trunk.registration_mode || (Number(trunk.register) === 1 ? 'send' : 'none')) : 'send'; + form.querySelectorAll('input[name="registration_mode"]').forEach(r => { + r.checked = (r.value === regMode); + }); + onTrunkRegistrationModeChange(regMode); + form.elements['transport'].value = trunk ? (trunk.transport ?? 'udp') : 'udp'; + const authMode = trunk ? (trunk.auth_mode || 'userpass') : 'userpass'; form.querySelectorAll('input[name="auth_mode"]').forEach(r => { r.checked = (r.value === authMode); @@ -2168,6 +2261,17 @@ function openTrunkModal(trunk = null) { form.elements['secret'].value = trunk ? (trunk.secret ?? '') : ''; form.elements['enabled'].checked = trunk ? Number(trunk.enabled ?? 1) === 1 : true; + const uInput = form.elements['username']; + const updateGuide = () => { + const val = uInput.value.trim() || 'Même valeur que Username'; + const gUser = document.getElementById('ht813-guide-user'); + const gAuth = document.getElementById('ht813-guide-auth'); + if (gUser) gUser.textContent = val; + if (gAuth) gAuth.textContent = val; + }; + uInput.oninput = updateGuide; + updateGuide(); + // Onglet 2 : Registration & Identité SIP form.elements['register'].checked = trunk ? Number(trunk.register ?? 1) === 1 : true; form.elements['line_support'].checked = trunk ? Number(trunk.line_support ?? 1) === 1 : true; @@ -3583,6 +3687,7 @@ document.addEventListener('DOMContentLoaded', () => { const fd = new FormData(form); const payload = Object.fromEntries(fd.entries()); + payload.registration_mode = form.querySelector('input[name="registration_mode"]:checked')?.value || 'send'; payload.enabled = form.elements['enabled'].checked ? 1 : 0; payload.register = form.elements['register'].checked ? 1 : 0; payload.line_support = form.elements['line_support'].checked ? 1 : 0; diff --git a/public/index.php b/public/index.php index 0dcc708..1e77f67 100644 --- a/public/index.php +++ b/public/index.php @@ -3278,6 +3278,9 @@ $currentUser = Auth::getUsername(); + @@ -3340,16 +3343,78 @@ $currentUser = Auth::getUsername();
+ +
+
+ Mode d'Enregistrement SIP (Registration) + Définit le sens de l'enregistrement ou le mode peering +
+
+ + + +
+ + + +
+
-
- - Serveur SIP / SBC / Passerelle * (Host / IP) + +
@@ -3520,8 +3585,13 @@ $currentUser = Auth::getUsername();
- +