feat: add support for inbound trunk registration (Receive mode) for FXO gateways like Grandstream HT813 and analog Livebox integration

This commit is contained in:
sorcier committed 2026-11-03 19:07:19 +01:00
1 parent 981c650c7a
commit 8a4d7a0504
5 files changed
+274 -26

No files matched your search

+29 -8
View File
@@ -1086,7 +1086,15 @@ try {
$username = trim((string)($payload['username'] ?? '')); $username = trim((string)($payload['username'] ?? ''));
$auth_username = trim((string)($payload['auth_username'] ?? '')); $auth_username = trim((string)($payload['auth_username'] ?? ''));
$secret = trim((string)($payload['secret'] ?? '')); $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'] ?? '')); $server_uri = trim((string)($payload['server_uri'] ?? ''));
$client_uri = trim((string)($payload['client_uri'] ?? '')); $client_uri = trim((string)($payload['client_uri'] ?? ''));
$contact_user = trim((string)($payload['contact_user'] ?? '')); $contact_user = trim((string)($payload['contact_user'] ?? ''));
@@ -1130,8 +1138,17 @@ try {
$cleanDidRoutes = ConfigGenerator::normalizeInboundRoutes(json_encode($didRoutesRaw)); $cleanDidRoutes = ConfigGenerator::normalizeInboundRoutes(json_encode($didRoutesRaw));
$did_routes_json = json_encode($cleanDidRoutes, JSON_UNESCAPED_UNICODE); $did_routes_json = json_encode($cleanDidRoutes, JSON_UNESCAPED_UNICODE);
if ($name === '' || $host === '') { if ($name === '') {
throw new InvalidArgumentException("Le nom du Trunk et l'hôte SIP opérateur sont requis."); 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 = [ $params = [
@@ -1144,6 +1161,7 @@ try {
':username' => $username, ':username' => $username,
':auth_username' => $auth_username, ':auth_username' => $auth_username,
':secret' => $secret, ':secret' => $secret,
':registration_mode' => $registration_mode,
':register' => $register, ':register' => $register,
':server_uri' => $server_uri, ':server_uri' => $server_uri,
':client_uri' => $client_uri, ':client_uri' => $client_uri,
@@ -1192,7 +1210,8 @@ try {
UPDATE trunks SET UPDATE trunks SET
name = :name, host = :host, port = :port, outbound_proxy = :outbound_proxy, name = :name, host = :host, port = :port, outbound_proxy = :outbound_proxy,
transport = :transport, auth_mode = :auth_mode, username = :username, 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, server_uri = :server_uri, client_uri = :client_uri, contact_user = :contact_user,
expiration = :expiration, retry_interval = :retry_interval, expiration = :expiration, retry_interval = :retry_interval,
max_retries = :max_retries, forbidden_retry_interval = :forbidden_retry_interval, max_retries = :max_retries, forbidden_retry_interval = :forbidden_retry_interval,
@@ -1217,7 +1236,7 @@ try {
$stmt = $db->prepare(' $stmt = $db->prepare('
INSERT INTO trunks ( INSERT INTO trunks (
name, host, port, outbound_proxy, transport, auth_mode, username, auth_username, secret, 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, max_retries, forbidden_retry_interval, fatal_retry_interval, line_support,
from_user, from_domain, outbound_callerid, send_pai, send_rpid, send_diversion, from_user, from_domain, outbound_callerid, send_pai, send_rpid, send_diversion,
trust_id_inbound, codecs, dtmf_mode, media_encryption, nat_symmetric, direct_media, 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 outbound_prefix, custom_endpoint_params, custom_reg_params, custom_aor_params, moh_sound_id, enabled
) VALUES ( ) VALUES (
:name, :host, :port, :outbound_proxy, :transport, :auth_mode, :username, :auth_username, :secret, :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, :max_retries, :forbidden_retry_interval, :fatal_retry_interval, :line_support,
:from_user, :from_domain, :outbound_callerid, :send_pai, :send_rpid, :send_diversion, :from_user, :from_domain, :outbound_callerid, :send_pai, :send_rpid, :send_diversion,
:trust_id_inbound, :codecs, :dtmf_mode, :media_encryption, :nat_symmetric, :direct_media, :trust_id_inbound, :codecs, :dtmf_mode, :media_encryption, :nat_symmetric, :direct_media,
@@ -1237,7 +1256,8 @@ try {
ON CONFLICT(name) DO UPDATE SET ON CONFLICT(name) DO UPDATE SET
host = excluded.host, port = excluded.port, outbound_proxy = excluded.outbound_proxy, host = excluded.host, port = excluded.port, outbound_proxy = excluded.outbound_proxy,
transport = excluded.transport, auth_mode = excluded.auth_mode, username = excluded.username, 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, server_uri = excluded.server_uri, client_uri = excluded.client_uri, contact_user = excluded.contact_user,
expiration = excluded.expiration, retry_interval = excluded.retry_interval, expiration = excluded.expiration, retry_interval = excluded.retry_interval,
max_retries = excluded.max_retries, forbidden_retry_interval = excluded.forbidden_retry_interval, max_retries = excluded.max_retries, forbidden_retry_interval = excluded.forbidden_retry_interval,
@@ -1259,9 +1279,10 @@ try {
$stmt->execute($params); $stmt->execute($params);
} }
$displayHost = $host !== '' ? "{$host}:{$port}" : "Passerelle locale / HT813";
echo json_encode([ echo json_encode([
'success' => true, '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; break;
+110 -5
View File
@@ -1579,14 +1579,25 @@ function renderTrunksTable() {
tbody.innerHTML = rows.map(t => { tbody.innerHTML = rows.map(t => {
const slug = `trunk-${slugifyTrunkName(t.name)}`; const slug = `trunk-${slugifyTrunkName(t.name)}`;
const regInfo = regs[slug]; const regInfo = regs[slug];
const regMode = t.registration_mode || (Number(t.register) === 1 ? 'send' : 'none');
const isIpAuth = t.auth_mode === 'ip'; const isIpAuth = t.auth_mode === 'ip';
const wantsReg = !isIpAuth && Number(t.register) === 1;
let statusBadge = ''; let statusBadge = '';
if (Number(t.enabled ?? 1) === 0) { if (Number(t.enabled ?? 1) === 0) {
statusBadge = `<span class="px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-400 border border-anthracite-700">Désactivé</span>`; statusBadge = `<span class="px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-400 border border-anthracite-700">Désactivé</span>`;
} else if (isIpAuth || !wantsReg) { } else if (regMode === 'receive') {
statusBadge = `<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30">Peering / Sans Reg.</span>`; 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 = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"><i class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></i>Passerelle En ligne</span>`;
} else {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30">En attente passerelle (Receive)</span>`;
}
} else if (isIpAuth || regMode === 'none') {
statusBadge = `<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-500/15 text-purple-300 border border-purple-500/30">Peering IP direct</span>`;
} else if (regInfo && /Registered/i.test(regInfo.status)) { } else if (regInfo && /Registered/i.test(regInfo.status)) {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"><i class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></i>Registered</span>`; statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"><i class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></i>Registered</span>`;
} else if (regInfo) { } else if (regInfo) {
@@ -1618,7 +1629,9 @@ function renderTrunksTable() {
</td> </td>
<td class="py-3.5 px-4">${statusBadge}</td> <td class="py-3.5 px-4">${statusBadge}</td>
<td class="py-3.5 px-4"> <td class="py-3.5 px-4">
<div class="font-mono text-xs text-indigo-300">${escapeHtml(t.host)}:${Number(t.port)}</div> <div class="font-mono text-xs ${t.host ? 'text-indigo-300' : 'text-emerald-300'}">
${t.host ? escapeHtml(t.host) + ':' + Number(t.port || 5060) : 'Passerelle locale (Dynamique)'}
</div>
${t.outbound_proxy ? `<div class="text-[11px] font-mono text-slate-400 truncate max-w-[180px]">Proxy: ${escapeHtml(t.outbound_proxy)}</div>` : ''} ${t.outbound_proxy ? `<div class="text-[11px] font-mono text-slate-400 truncate max-w-[180px]">Proxy: ${escapeHtml(t.outbound_proxy)}</div>` : ''}
</td> </td>
<td class="py-3.5 px-4"> <td class="py-3.5 px-4">
@@ -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 <span class="text-emerald-400 font-semibold">(Optionnel en mode Réception)</span>';
}
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 * <span class="text-slate-400">(Host / IP)</span>';
}
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 * <span class="text-slate-400">(Host / IP)</span>';
}
hostHint?.classList.add('hidden');
receiveGuide?.classList.add('hidden');
regNotice?.classList.add('hidden');
if (form.elements['register']) form.elements['register'].checked = true;
}
}
function applyTrunkArchitecture(arch) { function applyTrunkArchitecture(arch) {
const form = document.getElementById('form-trunk'); const form = document.getElementById('form-trunk');
if (!form) return; if (!form) return;
@@ -2095,17 +2159,31 @@ function applyTrunkArchitecture(arch) {
port: 5060, port: 5060,
transport: 'udp', transport: 'udp',
auth_mode: 'userpass', auth_mode: 'userpass',
registration_mode: 'send',
register: true, register: true,
line_support: true, line_support: true,
media_encryption: 'no', media_encryption: 'no',
nat_symmetric: true, nat_symmetric: true,
qualify_frequency: 60 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: { ip_peering: {
label: 'Peering IP / SBC / Passerelle (Sans REGISTER)', label: 'Peering IP / SBC / Passerelle (Sans REGISTER)',
port: 5060, port: 5060,
transport: 'udp', transport: 'udp',
auth_mode: 'ip', auth_mode: 'ip',
registration_mode: 'none',
register: false, register: false,
line_support: false, line_support: false,
media_encryption: 'no', media_encryption: 'no',
@@ -2117,6 +2195,7 @@ function applyTrunkArchitecture(arch) {
port: 5061, port: 5061,
transport: 'tls', transport: 'tls',
auth_mode: 'userpass', auth_mode: 'userpass',
registration_mode: 'send',
register: true, register: true,
line_support: true, line_support: true,
media_encryption: 'sdes', 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); }); form.querySelectorAll('input[name="auth_mode"]').forEach(r => { r.checked = (r.value === a.auth_mode); });
toggleTrunkAuthFields(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['register'].checked = a.register;
form.elements['line_support'].checked = a.line_support; form.elements['line_support'].checked = a.line_support;
form.elements['media_encryption'].value = a.media_encryption; 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['id'].value = trunk ? trunk.id : '';
form.elements['name'].value = trunk ? trunk.name : ''; 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['port'].value = trunk ? (trunk.port ?? 5060) : 5060;
form.elements['outbound_proxy'].value = trunk ? (trunk.outbound_proxy ?? '') : ''; form.elements['outbound_proxy'].value = trunk ? (trunk.outbound_proxy ?? '') : '';
form.elements['transport'].value = trunk ? (trunk.transport ?? 'udp') : 'udp'; 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'; const authMode = trunk ? (trunk.auth_mode || 'userpass') : 'userpass';
form.querySelectorAll('input[name="auth_mode"]').forEach(r => { form.querySelectorAll('input[name="auth_mode"]').forEach(r => {
r.checked = (r.value === authMode); r.checked = (r.value === authMode);
@@ -2168,6 +2261,17 @@ function openTrunkModal(trunk = null) {
form.elements['secret'].value = trunk ? (trunk.secret ?? '') : ''; form.elements['secret'].value = trunk ? (trunk.secret ?? '') : '';
form.elements['enabled'].checked = trunk ? Number(trunk.enabled ?? 1) === 1 : true; 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 // Onglet 2 : Registration & Identité SIP
form.elements['register'].checked = trunk ? Number(trunk.register ?? 1) === 1 : true; form.elements['register'].checked = trunk ? Number(trunk.register ?? 1) === 1 : true;
form.elements['line_support'].checked = trunk ? Number(trunk.line_support ?? 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 fd = new FormData(form);
const payload = Object.fromEntries(fd.entries()); 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.enabled = form.elements['enabled'].checked ? 1 : 0;
payload.register = form.elements['register'].checked ? 1 : 0; payload.register = form.elements['register'].checked ? 1 : 0;
payload.line_support = form.elements['line_support'].checked ? 1 : 0; payload.line_support = form.elements['line_support'].checked ? 1 : 0;
+74 -4
View File
@@ -3278,6 +3278,9 @@ $currentUser = Auth::getUsername();
<button type="button" onclick="applyTrunkArchitecture('register_auth')" class="px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-indigo-600/30 border border-anthracite-600 text-indigo-300 font-medium transition"> <button type="button" onclick="applyTrunkArchitecture('register_auth')" class="px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-indigo-600/30 border border-anthracite-600 text-indigo-300 font-medium transition">
Trunk avec Enregistrement (REGISTER + Auth) Trunk avec Enregistrement (REGISTER + Auth)
</button> </button>
<button type="button" onclick="applyTrunkArchitecture('gateway_receive')" class="px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-emerald-600/30 border border-emerald-500/50 text-emerald-300 font-medium transition flex items-center gap-1">
<span>📟 Passerelle FXO / Analogique (HT813, Livebox)</span>
</button>
<button type="button" onclick="applyTrunkArchitecture('ip_peering')" class="px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-indigo-600/30 border border-anthracite-600 text-purple-300 font-medium transition"> <button type="button" onclick="applyTrunkArchitecture('ip_peering')" class="px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-indigo-600/30 border border-anthracite-600 text-purple-300 font-medium transition">
Peering IP / SBC / Passerelle (Sans REGISTER) Peering IP / SBC / Passerelle (Sans REGISTER)
</button> </button>
@@ -3340,16 +3343,78 @@ $currentUser = Auth::getUsername();
<!-- SOUS-ONGLET 1 : SERVEUR, AUTHENTIFICATION & ROUTAGE DIRECT --> <!-- SOUS-ONGLET 1 : SERVEUR, AUTHENTIFICATION & ROUTAGE DIRECT -->
<div id="trunk-panel-general" class="trunk-subpanel space-y-4"> <div id="trunk-panel-general" class="trunk-subpanel space-y-4">
<!-- Mode d'Enregistrement SIP (Send / Receive / None) -->
<div class="p-4 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-xs font-bold uppercase tracking-wider text-emerald-300">Mode d'Enregistrement SIP (Registration)</span>
<span class="text-[11px] text-slate-400">Définit le sens de l'enregistrement ou le mode peering</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
<label class="flex items-start gap-2.5 p-3 rounded-xl bg-anthracite-900 border border-anthracite-700 cursor-pointer hover:border-indigo-500/50 transition">
<input type="radio" name="registration_mode" value="send" checked onchange="onTrunkRegistrationModeChange(this.value)" class="mt-0.5">
<div>
<div class="text-white font-semibold flex items-center gap-1.5">
<span>Émission (Send)</span>
<span class="text-[10px] px-1.5 py-0.2 rounded bg-indigo-500/20 text-indigo-300">Défaut</span>
</div>
<div class="text-[11px] text-slate-400 mt-0.5">Asterisk s'enregistre vers l'opérateur (OVH, Keyyo...). Serveur SIP requis.</div>
</div>
</label>
<label class="flex items-start gap-2.5 p-3 rounded-xl bg-anthracite-900 border border-emerald-500/40 cursor-pointer hover:border-emerald-500 transition">
<input type="radio" name="registration_mode" value="receive" onchange="onTrunkRegistrationModeChange(this.value)" class="mt-0.5">
<div>
<div class="text-emerald-300 font-semibold flex items-center gap-1.5">
<span>Réception (Receive)</span>
<span class="text-[10px] px-1.5 py-0.2 rounded bg-emerald-500/20 text-emerald-300">Passerelle FXO</span>
</div>
<div class="text-[11px] text-slate-300 mt-0.5">La passerelle s'enregistre sur Asterisk (Grandstream HT813, Livebox). Hôte libre.</div>
</div>
</label>
<label class="flex items-start gap-2.5 p-3 rounded-xl bg-anthracite-900 border border-anthracite-700 cursor-pointer hover:border-purple-500/50 transition">
<input type="radio" name="registration_mode" value="none" onchange="onTrunkRegistrationModeChange(this.value)" class="mt-0.5">
<div>
<div class="text-purple-300 font-semibold flex items-center gap-1.5">
<span>Aucun (None)</span>
<span class="text-[10px] px-1.5 py-0.2 rounded bg-purple-500/20 text-purple-300">Peering IP</span>
</div>
<div class="text-[11px] text-slate-400 mt-0.5">Interconnexion directe sans enregistrement (Trunk IP dédié, SBC). Serveur SIP requis.</div>
</div>
</label>
</div>
<!-- Guide interactif d'aide pour Grandstream HT813 / Livebox FXO -->
<div id="trunk-receive-guide" class="hidden p-3.5 rounded-xl bg-emerald-950/40 border border-emerald-500/40 text-xs text-slate-200 space-y-2">
<div class="flex items-center gap-2 font-bold text-emerald-300 text-xs">
<span>💡 Guide Rapide : Paramétrage de votre Grandstream HT813 / HT503 (FXO)</span>
</div>
<p class="text-[11px] text-slate-300 leading-relaxed">
Dans l'interface web du boîtier HT813 (onglet <strong>FXO Port</strong> relié à la prise téléphone verte de votre Livebox / Box opérateur) :
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-[11px] font-mono bg-anthracite-950 p-3 rounded-lg border border-emerald-500/25">
<div>• <strong>Primary SIP Server</strong> : <span class="text-emerald-300 font-bold"><?= htmlspecialchars($_SERVER['SERVER_ADDR'] ?? '10.10.175.204') ?>:5060</span></div>
<div>• <strong>SIP Registration</strong> : <span class="text-emerald-300 font-bold">Yes</span></div>
<div>• <strong>SIP User ID</strong> : <span class="text-indigo-300 font-bold" id="ht813-guide-user">Même valeur que Username</span></div>
<div>• <strong>Authenticate ID</strong> : <span class="text-indigo-300 font-bold" id="ht813-guide-auth">Même valeur que Username</span></div>
<div>• <strong>Authenticate Password</strong> : <span class="text-indigo-300 font-bold">Votre mot de passe secret</span></div>
<div>• <strong>Stage Method (1/2)</strong> : <span class="text-slate-300">1 (Recommandé pour appel direct)</span></div>
<div class="sm:col-span-2 text-slate-300 font-sans border-t border-anthracite-800 pt-2 mt-1">
• <strong>Unconditional Call Forward to VoIP</strong> : Définir <em>User ID</em> = votre numéro (ex: <code class="text-amber-300 font-mono">0494679572</code>) et <em>SIP Server</em> = <code class="text-amber-300 font-mono">ch1:<?= htmlspecialchars($_SERVER['SERVER_ADDR'] ?? '10.10.175.204') ?>:5060;</code>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-4 gap-4">
<div class="sm:col-span-1"> <div class="sm:col-span-1">
<label class="block text-xs font-medium text-slate-200 mb-1">Nom du Trunk * <span class="text-slate-400">(ID PJSIP)</span></label> <label class="block text-xs font-medium text-slate-200 mb-1">Nom du Trunk * <span class="text-slate-400">(ID PJSIP)</span></label>
<input type="text" name="name" placeholder="ex: mon-trunk-sip" required <input type="text" name="name" placeholder="ex: mon-trunk-sip ou 0494679572" required
class="w-full px-3 py-2 rounded-lg bg-anthracite-950 border border-anthracite-700 text-sm text-white font-mono"> class="w-full px-3 py-2 rounded-lg bg-anthracite-950 border border-anthracite-700 text-sm text-white font-mono">
</div> </div>
<div class="sm:col-span-2"> <div class="sm:col-span-2">
<label class="block text-xs font-medium text-slate-200 mb-1">Serveur SIP / SBC / Passerelle * <span class="text-slate-400">(Host / IP)</span></label> <label id="trunk-host-label" class="block text-xs font-medium text-slate-200 mb-1">Serveur SIP / SBC / Passerelle * <span class="text-slate-400">(Host / IP)</span></label>
<input type="text" name="host" placeholder="ex: sbc.operateur.net ou 192.168.1.250" required oninput="syncTrunkHostDomain(this.value)" <input type="text" name="host" id="trunk-host-input" placeholder="ex: sbc.operateur.net ou 192.168.1.250" required oninput="syncTrunkHostDomain(this.value)"
class="w-full px-3 py-2 rounded-lg bg-anthracite-950 border border-anthracite-700 text-sm text-white font-mono"> class="w-full px-3 py-2 rounded-lg bg-anthracite-950 border border-anthracite-700 text-sm text-white font-mono">
<div id="trunk-host-hint" class="hidden text-[10px] text-emerald-400 mt-1">En mode Réception (Receive), l'hôte peut rester vide (découverte dynamique du HT813 lors du REGISTER).</div>
</div> </div>
<div class="sm:col-span-1"> <div class="sm:col-span-1">
<label class="block text-xs font-medium text-slate-200 mb-1">Port SIP * <span class="text-slate-400">(Port)</span></label> <label class="block text-xs font-medium text-slate-200 mb-1">Port SIP * <span class="text-slate-400">(Port)</span></label>
@@ -3520,8 +3585,13 @@ $currentUser = Auth::getUsername();
</div> </div>
</div> </div>
<!-- SOUS-ONGLET 2 : ENREGISTREMENT (REGISTRATION) & IDENTITÉ SIP --> <!-- SOUS-ONGLET 3 : ENREGISTREMENT (REGISTRATION) & IDENTITÉ SIP -->
<div id="trunk-panel-identity" class="trunk-subpanel hidden space-y-4"> <div id="trunk-panel-identity" class="trunk-subpanel hidden space-y-4">
<div id="trunk-reg-outbound-notice" class="hidden p-3.5 rounded-xl bg-indigo-950/40 border border-indigo-500/40 text-xs text-indigo-200 flex items-center gap-2">
<span>ℹ️</span>
<span>En mode <strong>Réception (Passerelle type HT813)</strong> ou <strong>Peering IP direct (None)</strong>, Asterisk n'émet aucun SIP REGISTER sortant. Les paramètres d'enregistrement ci-dessous ne s'appliquent qu'en mode <strong>Émission (Send)</strong>.</span>
</div>
<div class="p-4 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-4"> <div class="p-4 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-xs font-bold uppercase tracking-wider text-emerald-300">Paramètres d'Enregistrement SIP (<code class="text-emerald-300">[type = registration]</code>)</span> <span class="text-xs font-bold uppercase tracking-wider text-emerald-300">Paramètres d'Enregistrement SIP (<code class="text-emerald-300">[type = registration]</code>)</span>
+55 -8
View File
@@ -308,15 +308,40 @@ class ConfigGenerator
$codecs = array_filter(array_map('trim', explode(',', (string)($trunk['codecs'] ?? 'alaw,ulaw')))); $codecs = array_filter(array_map('trim', explode(',', (string)($trunk['codecs'] ?? 'alaw,ulaw'))));
$hasAuth = ($authMode !== 'ip' && $username !== '' && $secret !== ''); $hasAuth = ($authMode !== 'ip' && $username !== '' && $secret !== '');
$lines[] = "; --- Trunk : {$trunk['name']} ({$host}:{$port} [{$trProto}]) ---"; $regMode = trim((string)($trunk['registration_mode'] ?? ''));
if (!in_array($regMode, ['send', 'receive', 'none'], true)) {
$regMode = ((int)($trunk['register'] ?? 1) === 1) ? 'send' : 'none';
}
$matchIpsRaw = trim((string)($trunk['match_ips'] ?? ''));
$hostPortDisplay = $host !== '' ? "{$host}:{$port}" : "Passerelle Réception Dynamique";
$lines[] = "; --- Trunk : {$trunk['name']} ({$hostPortDisplay} [{$trProto}] - Mode: {$regMode}) ---";
$lines[] = "[trunk-{$slug}]"; $lines[] = "[trunk-{$slug}]";
$lines[] = "type = endpoint"; $lines[] = "type = endpoint";
$lines[] = "context = {$context}"; $lines[] = "context = {$context}";
$lines[] = "transport = {$transport}"; $lines[] = "transport = {$transport}";
if ($regMode === 'receive') {
// Passerelle locale (ex: Grandstream HT813, Livebox, ATA) s'enregistrant sur Asterisk
$lines[] = "auth = trunk-{$slug}-auth";
$lines[] = "outbound_auth = trunk-{$slug}-auth";
if ($username !== '' && $username !== "trunk-{$slug}") {
$lines[] = "aors = trunk-{$slug},{$username}";
} else {
$lines[] = "aors = trunk-{$slug}";
}
if ($host !== '' || $matchIpsRaw !== '') {
$lines[] = "identify_by = auth_username,username,ip";
} else {
$lines[] = "identify_by = auth_username,username";
}
} else {
if ($hasAuth) { if ($hasAuth) {
$lines[] = "outbound_auth = trunk-{$slug}-auth"; $lines[] = "outbound_auth = trunk-{$slug}-auth";
} }
$lines[] = "aors = trunk-{$slug}"; $lines[] = "aors = trunk-{$slug}";
}
if ($outboundProxy !== '') { if ($outboundProxy !== '') {
$lines[] = "outbound_proxy = {$outboundProxy}"; $lines[] = "outbound_proxy = {$outboundProxy}";
} }
@@ -377,10 +402,16 @@ class ConfigGenerator
} }
$lines[] = ""; $lines[] = "";
// Section AOR du Trunk (nommée trunk-$slug) // Section AOR du Trunk (nommée trunk-$slug et alias username si mode receive)
$lines[] = "[trunk-{$slug}]"; $lines[] = "[trunk-{$slug}]";
$lines[] = "type = aor"; $lines[] = "type = aor";
if ($regMode === 'receive') {
// En mode réception, l'IP/port de la passerelle est découvert dynamiquement lors du REGISTER
$lines[] = "max_contacts = 1";
$lines[] = "remove_existing = yes";
} else {
$lines[] = "contact = sip:{$host}:{$port}"; $lines[] = "contact = sip:{$host}:{$port}";
}
$lines[] = "qualify_frequency = {$qualifyFreq}"; $lines[] = "qualify_frequency = {$qualifyFreq}";
if ($outboundProxy !== '') { if ($outboundProxy !== '') {
$lines[] = "outbound_proxy = {$outboundProxy}"; $lines[] = "outbound_proxy = {$outboundProxy}";
@@ -396,8 +427,21 @@ class ConfigGenerator
} }
$lines[] = ""; $lines[] = "";
// AOR supplémentaire pour identifier l'enregistrement par identifiant/numéro (ex: 0494679572)
if ($regMode === 'receive' && $username !== '' && $username !== "trunk-{$slug}") {
$lines[] = "[{$username}]";
$lines[] = "type = aor";
$lines[] = "max_contacts = 1";
$lines[] = "remove_existing = yes";
$lines[] = "qualify_frequency = {$qualifyFreq}";
if ($outboundProxy !== '') {
$lines[] = "outbound_proxy = {$outboundProxy}";
}
$lines[] = "";
}
// Section Auth du Trunk // Section Auth du Trunk
if ($hasAuth) { if ($hasAuth || $regMode === 'receive') {
$lines[] = "[trunk-{$slug}-auth]"; $lines[] = "[trunk-{$slug}-auth]";
$lines[] = "type = auth"; $lines[] = "type = auth";
$lines[] = "auth_type = userpass"; $lines[] = "auth_type = userpass";
@@ -406,8 +450,8 @@ class ConfigGenerator
$lines[] = ""; $lines[] = "";
} }
// Section Registration du Trunk // Section Registration du Trunk (uniquement en mode émission 'send')
if ((int)($trunk['register'] ?? 1) === 1 && $hasAuth) { if ($regMode === 'send' && (int)($trunk['register'] ?? 1) === 1 && $hasAuth) {
$serverUri = trim((string)($trunk['server_uri'] ?? '')) ?: "sip:{$host}:{$port}"; $serverUri = trim((string)($trunk['server_uri'] ?? '')) ?: "sip:{$host}:{$port}";
$clientUri = trim((string)($trunk['client_uri'] ?? '')) ?: "sip:{$username}@{$fromDomain}:{$port}"; $clientUri = trim((string)($trunk['client_uri'] ?? '')) ?: "sip:{$username}@{$fromDomain}:{$port}";
$contactUser = trim((string)($trunk['contact_user'] ?? '')); $contactUser = trim((string)($trunk['contact_user'] ?? ''));
@@ -453,12 +497,13 @@ class ConfigGenerator
$lines[] = ""; $lines[] = "";
} }
// Section Identify (Reconnaissance d'IPs / sous-réseaux opérateur) // Section Identify (Reconnaissance d'IPs / sous-réseaux opérateur ou passerelle)
$matchIpsRaw = trim((string)($trunk['match_ips'] ?? '')); if ($matchIpsRaw !== '' || ($regMode !== 'receive' && $host !== '') || ($regMode === 'receive' && $host !== '')) {
$matchList = $matchIpsRaw !== '' $matchList = $matchIpsRaw !== ''
? array_filter(array_map('trim', preg_split('/[\s,;]+/', $matchIpsRaw) ?: [])) ? array_filter(array_map('trim', preg_split('/[\s,;]+/', $matchIpsRaw) ?: []))
: [$host]; : ($host !== '' ? [$host] : []);
if (!empty($matchList)) {
$lines[] = "[trunk-{$slug}-identify]"; $lines[] = "[trunk-{$slug}-identify]";
$lines[] = "type = identify"; $lines[] = "type = identify";
$lines[] = "endpoint = trunk-{$slug}"; $lines[] = "endpoint = trunk-{$slug}";
@@ -468,6 +513,8 @@ class ConfigGenerator
$lines[] = ""; $lines[] = "";
} }
} }
}
}
return implode("\n", $lines) . "\n"; return implode("\n", $lines) . "\n";
} }
+6 -1
View File
@@ -128,7 +128,7 @@ class Database
CREATE TABLE IF NOT EXISTS trunks ( CREATE TABLE IF NOT EXISTS trunks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(64) NOT NULL UNIQUE,
host VARCHAR(128) NOT NULL, host VARCHAR(128) NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 5060, port INTEGER NOT NULL DEFAULT 5060,
outbound_proxy VARCHAR(128) NOT NULL DEFAULT '', outbound_proxy VARCHAR(128) NOT NULL DEFAULT '',
transport VARCHAR(8) NOT NULL DEFAULT 'udp', transport VARCHAR(8) NOT NULL DEFAULT 'udp',
@@ -136,6 +136,7 @@ class Database
username VARCHAR(64) NOT NULL DEFAULT '', username VARCHAR(64) NOT NULL DEFAULT '',
auth_username VARCHAR(64) NOT NULL DEFAULT '', auth_username VARCHAR(64) NOT NULL DEFAULT '',
secret VARCHAR(128) NOT NULL DEFAULT '', secret VARCHAR(128) NOT NULL DEFAULT '',
registration_mode VARCHAR(16) NOT NULL DEFAULT 'send' CHECK (registration_mode IN ('send', 'receive', 'none')),
register INTEGER NOT NULL DEFAULT 1 CHECK (register IN (0, 1)), register INTEGER NOT NULL DEFAULT 1 CHECK (register IN (0, 1)),
server_uri VARCHAR(192) NOT NULL DEFAULT '', server_uri VARCHAR(192) NOT NULL DEFAULT '',
client_uri VARCHAR(192) NOT NULL DEFAULT '', client_uri VARCHAR(192) NOT NULL DEFAULT '',
@@ -561,6 +562,7 @@ class Database
// Colonnes universelles PJSIP supplémentaires (Session Timers, Fax T.38, Diversion, Retries, Langue, Keepalive) // Colonnes universelles PJSIP supplémentaires (Session Timers, Fax T.38, Diversion, Retries, Langue, Keepalive)
$extraColumns = [ $extraColumns = [
'registration_mode' => "VARCHAR(16) NOT NULL DEFAULT 'send'",
'max_retries' => "INTEGER NOT NULL DEFAULT 10000", 'max_retries' => "INTEGER NOT NULL DEFAULT 10000",
'forbidden_retry_interval' => "INTEGER NOT NULL DEFAULT 600", 'forbidden_retry_interval' => "INTEGER NOT NULL DEFAULT 600",
'fatal_retry_interval' => "INTEGER NOT NULL DEFAULT 0", 'fatal_retry_interval' => "INTEGER NOT NULL DEFAULT 0",
@@ -579,6 +581,9 @@ class Database
foreach ($extraColumns as $colName => $colDef) { foreach ($extraColumns as $colName => $colDef) {
if (!in_array($colName, $existing, true)) { if (!in_array($colName, $existing, true)) {
$db->exec("ALTER TABLE trunks ADD COLUMN {$colName} {$colDef}"); $db->exec("ALTER TABLE trunks ADD COLUMN {$colName} {$colDef}");
if ($colName === 'registration_mode') {
$db->exec("UPDATE trunks SET registration_mode = CASE WHEN register = 1 THEN 'send' ELSE 'none' END");
}
} }
} }
} }