TL;DR
ModuleAutoprovision (PnP-провижн для Yealink/Snom/Fanvil/Grandstream) не работает на части машин клиентов: телефон шлёт multicast SUBSCRIBE на 224.0.1.75:5060, наш PHP-воркер ловит и формирует NOTIFY с URL конфигурации, но Asterisk (pjsip) на bind=0.0.0.0:5060 успевает ответить первым — 401 Unauthorized либо 400 Bad Request. Телефон, по дизайну протокола, доверяет первому пришедшему ответу — то есть берёт ответ от pjsip и игнорирует наш правильный NOTIFY. PnP молча отваливается.
Проблема архитектурная и решается только в Core, в SIPConf::generateTransports(). Подробности, эмпирика, обоснование выбора решения — ниже.
Сообщает клиент @shaxov92 (Yealink T19 на MikoPBX 2026.1.223), воспроизведено локально на 172.16.32.94 (MikoPBX 2026.2.89-dev + Asterisk 22.8.2).
1. Симптом, на стороне клиента
Дамп tcpdump у @shaxov92 (Yealink T19, FW 53.84.0.140, MikoPBX 2026.1.223, ModuleAutoprovision 1.66):
192.168.1.60.5060 > 192.168.1.104.5059: SIP/2.0 401 Unauthorized
From: <sip:MAC805ec086888f@224.0.1.75>;tag=...
WWW-Authenticate: Digest realm="asterisk", nonce=..., qop="auth"
Server: PBX
То есть Asterisk шлёт auth challenge на multicast PnP-SUBSCRIBE. Наш NOTIFY приходит позже (если приходит) — телефон уже разорвал диалог по 401 и URL конфигурации не подхватывает.
2. Корневая причина — Linux kernel hook order
Воркер слушает multicast через SOCK_RAW + MCAST_JOIN_GROUP:
// Lib/WorkerProvisioningServerPnP.php:287-303
$sock = @socket_create(AF_INET, SOCK_RAW, SOL_UDP);
foreach ($this->interfaces as $eth) {
socket_set_option($sock, IPPROTO_IP, MCAST_JOIN_GROUP, ['group' => '224.0.1.75', 'interface' => $eth]);
}
socket_bind($sock, '224.0.1.75', 5060);
MCAST_JOIN_GROUP делает host-level IGMP-членство на интерфейсе. Это нужно физически — без членства NIC отбрасывает multicast-кадры на L2-уровне, kernel вообще ничего не видит (мы проверили: pkill worker → /proc/net/igmp теряет запись 4B0100E0 → multicast SUBSCRIBE до Asterisk не доходит).
Но host-level membership имеет побочный эффект: kernel начинает доставлять multicast-пакеты всем сокетам с подходящим bind (__udp4_lib_mcast_deliver в net/ipv4/udp.c обходит весь hash chain). Под это попадает и pjsip-UDP, который у нас всегда биндится на wildcard:
// Core/src/Core/Asterisk/Configs/SIPConf.php:953-957
$conf .= "[transport-udp]\n"
. "type = transport\n"
. "protocol = udp\n"
. "bind=0.0.0.0:{$transportParams['sipPort']}\n"
. "{$transportParams['natConf']}\n\n";
Wildcard 0.0.0.0:5060 матчится под multicast-цель 224.0.1.75:5060 → kernel доставляет пакет в pjsip → pjsip генерит ответ в соответствии со своей endpoint-логикой (401, 400, 489 — зависит от конкретного endpoint_identifier_order / наличия anonymous endpoint).
То есть наш собственный воркер невольно подставляет pjsip: мы регистрируем host как члена multicast-группы, а pjsip пользуется этим как «бесплатным» подписчиком на ту же группу.
Порядок hook'ов в ядре
Для полноты, путь IPv4-multicast UDP в Linux:
netif_receive_skb ← AF_PACKET слушал бы здесь (до NF)
└─ ip_rcv
└─ ip_route_input_mc (multicast routing, проверяет mc-membership)
└─ ip_local_deliver
└─ NF_HOOK(NF_INET_LOCAL_IN, ...) ← iptables INPUT
└─ ip_local_deliver_finish
└─ ip_protocol_deliver_rcu
├─ raw_local_deliver() ← наш SOCK_RAW worker
└─ ipprot->handler = udp_rcv ← pjsip 0.0.0.0:5060
Из этого следует — детерминированно, не зависит от прошивки/дистрибутива:
- Все UDP-сокеты, что матчатся на
(dst-ip, dst-port), получают копию пакета.
- iptables
INPUT … 224.0.1.75 udp/5060 -j DROP режет оба (и pjsip, и наш raw-сокет — это эмпирически подтверждено, см. п.3).
- AF_PACKET в воркере перехватывал бы пакет до NF, но PHP ext-sockets не экспортирует AF_PACKET (
socket_create(): Argument #1 ($domain) must be one of AF_UNIX, AF_INET6, or AF_INET — проверено на текущей сборке PHP 8.3 в MikoPBX).
3. Эмпирические данные (172.16.32.94 — MikoPBX, 172.16.32.177 — AlmaLinux-эмулятор фантома Yealink)
| Сценарий |
host-membership 224.0.1.75 |
pjsip получает |
pjsip отвечает |
worker отвечает |
Кто выиграл race |
bind=0.0.0.0, worker жив |
да |
да |
400 Bad Request @ +1ms |
NOTIFY @ +59ms |
pjsip (Δ -58ms) |
bind=0.0.0.0, worker мёртв (kill-loop) |
нет |
нет (NIC filter рубит на L2) |
тишина |
(нет worker'а) |
— |
bind=172.16.32.94, worker жив |
да (worker) |
нет (unicast bind не матчит multicast dst) |
тишина |
NOTIFY @ +123ms |
worker (бесконтенстно) |
iptables INPUT … 224.0.1.75 udp/5060 -j DROP, worker жив |
да |
нет |
тишина |
(worker тоже не получил, counter +1 пакет) |
— (всё блокировано) |
Все измерения с tcpdump -i any -tttt + python-эмулятор SUBSCRIBE с реальным Event: ua-profile;profile-type="device";vendor="Yealink";.... Полный набор команд воспроизведения — в .claude/worktrees/claude-md-diagnostics/TASK_pnp_pjsip_race.md и обновлённой секции «Diagnostics on a live MikoPBX» в CLAUDE.md.
Третья строка таблицы — мы временно меняли bind=0.0.0.0:5060 на bind=172.16.32.94:5060 через прямую правку /etc/asterisk/pjsip.conf + core restart now, прогоняли тест, восстанавливали (md5 совпал). Это и есть прототип предлагаемого фикса.
4. Что не работает (рассмотрено и отклонено)
| Вариант |
Почему отклонён |
iptables INPUT -d 224.0.1.75 -p udp --dport 5060 -j DROP |
Зарезает и воркер тоже (kernel hook order, см. п.2). Эмпирически подтверждено: counter +1 пакет, в autoprovision-pnp тишина. |
| AF_PACKET в воркере + iptables DROP |
PHP ext-sockets не поддерживает AF_PACKET. Потребует C-helper / pcap-extension в Core build — несоразмерно объёмная правка. |
| «Тихий» pjsip endpoint для multicast (анонимный, no auth) |
Pjsip всё равно ответит чем-то (489, 481, 400) — поведение прошивок Yealink/Snom/Fanvil на эти ответы vendor-specific, гарантии нет. match_request_uri требует Asterisk 18+. |
Дополнительный [transport-udp-mcast] bind=224.0.1.75:5060 рядом с основным [transport-udp] bind=0.0.0.0:5060 |
Linux доставляет multicast UDP всем подходящим сокетам, оба ответят. Архитектурно не работает в Linux UDP-стеке. |
| «Отучить» воркер от MCAST_JOIN_GROUP |
Самопротиворечие: без membership host вообще не получает multicast, воркер не работает. |
| «Телефон сам разберётся, мы доверять должны протоколу» (мнение разработчика) |
Документально опровергнуто: Yealink/Snom/Fanvil реализуют «first response wins» для PnP multicast SUBSCRIBE. См. п.6. |
5. Предлагаемое решение
Per-interface unicast bind для transport-udp в SIPConf::generateTransports(). Эмпирически проверено на 172.16.32.94, работает чисто.
5.1 Минимальная версия (PR-1, безопасный, не решает race, но устраняет «грабли»)
Сразу в 1.67 — гигиенический патч из трёх строчек в SIPConf::generateTransports() для всех 4 транспортов:
[transport-udp]
type = transport
protocol = udp
bind=0.0.0.0:{sipPort}
allow_reload=no ; ← НОВОЕ. Явно: bind не пересоздаётся на reload.
tos=cs3 ; ← НОВОЕ. QoS-маркировка (DSCP 24).
cos=3 ; ← НОВОЕ. 802.1p priority.
{natConf}
Источник: так делает FreePBX (/var/www/html/admin/modules/core/functions.inc/drivers/PJSip.class.php:1001, 1006-1007). Никаких функциональных изменений — только явно признаём то, что мы и так уже видели на стенде: pjsip не делает hot-reload bind/transport, попытка через pjsip reload или module reload res_pjsip.so тихо игнорируется. Применение нового bind требует core restart now либо полного unload/load res_pjsip.so (которое падает на зависимостях). allow_reload=no снимает иллюзию, что reload-достаточно.
Регресс-риск: ноль. ~10 LOC.
5.2 Основной фикс race-condition (PR-2, может ждать 1.68)
В Core/src/Core/Asterisk/Configs/SIPConf.php:947-989 (метод generateTransports) — превратить блок [transport-udp] в цикл по Network::getEnabledLanInterfaces():
private function generateTransports(array $transportParams): string
{
$conf = '';
$typeTransport = 'type = transport';
$sipPort = $transportParams['sipPort'];
$lanIfaces = Network::getEnabledLanInterfaces(); // уже используется в SIPConf.php:233, :2584
$hasAnyLanIp = false;
foreach ($lanIfaces as $iface) {
if (empty($iface->ipaddr)) {
continue; // интерфейс enabled, но IP ещё не получен (DHCP pending) — пропускаем
}
$hasAnyLanIp = true;
$isInternet = !empty($iface->internet);
// primary LAN сохраняет имя `transport-udp` ради обратной совместимости
// с endpoint'ами, которые ссылаются на это имя явно
$name = $isInternet ? 'transport-udp' : "transport-udp-{$iface->id}";
$conf .= "[{$name}]\n"
. "{$typeTransport}\n"
. "protocol = udp\n"
. "bind={$iface->ipaddr}:{$sipPort}\n"
. "allow_reload=no\n"
. "tos=cs3\ncos=3\n"
. ($isInternet ? "{$transportParams['natConf']}\n" : "")
. "\n";
}
// Fallback: если ни одного LAN-интерфейса с IP — не «кирпичим» PBX,
// оставляем wildcard, чтобы хоть как-то работало
if (!$hasAnyLanIp) {
$conf .= "[transport-udp]\n"
. "{$typeTransport}\nprotocol = udp\n"
. "bind=0.0.0.0:{$sipPort}\nallow_reload=no\ntos=cs3\ncos=3\n"
. "{$transportParams['natConf']}\n\n";
}
// [transport-tcp] — аналогично (TCP не страдает от race, но единообразие
// упрощает endpoint-конфигурацию). См. SIPConf.php:960-964.
// ... (тот же цикл, protocol=tcp)
// IPv6 — не трогаем в этом PR (см. п.5.4).
if ($this->hasIpv6Interfaces()) {
$conf .= "[transport-udp-ipv6]\n{$typeTransport}\n"
. "protocol = udp\nbind=[::]:{$sipPort}\n"
. "allow_reload=no\ntos=cs3\ncos=3\n"
. "{$transportParams['natConf']}\n\n";
$conf .= "[transport-tcp-ipv6]\n{$typeTransport}\n"
. "protocol = tcp\nbind=[::]:{$sipPort}\n"
. "allow_reload=no\ntos=cs3\ncos=3\n"
. "{$transportParams['natConf']}\n\n";
}
if (!empty($transportParams['certs']['certPath']) && !empty($transportParams['certs']['keyPath'])) {
$conf .= $this->generateSecureTransports($transportParams);
}
return $conf;
}
5.3 Обязательный сопутствующий триггер
Сейчас, при смене IP интерфейса (DHCP renewal, ручная переконфигурация), pjsip продолжает слушать старый IP до full restart Asterisk. С bind=0.0.0.0 это незаметно (wildcard ловит новый IP автоматически). После фикса — становится регрессом, ломающим DHCP-сценарии.
Митигация: в save-hook модели LanInterfaces (либо в имеющемся SystemControl::reloadAsterisk / эквивалент) при изменении ipaddr интерфейса с disabled=0 запускать:
- Регенерацию
/etc/asterisk/pjsip.conf через SIPConf::reConfigure.
asterisk -rx "core restart now" (а не pjsip reload — он не пересоздаст транспортные сокеты, мы это эмпирически подтвердили).
Это обязательная часть PR-2. Без неё фикс ломает мобильные сценарии без видимого симптома (телефоны просто перестают регистрироваться после смены IP, диагностика непростая).
5.4 Что не делаем в этом PR
- IPv6 не переводим на per-interface. Причина: race на IPv6 не существует, потому что наш PnP-воркер джоинит только IPv4-группу
224.0.1.75. Соответственно, kernel не доставляет multicast в pjsip-IPv6 (host не member ни одной IPv6 SIP-multicast группы). Симметричный per-interface bind для IPv6 — отдельный hygiene-PR в будущем.
- TLS/WSS оставляем как есть (
bind=0.0.0.0:5062 и :8089). Они unicast-only, не страдают от race. Опционально можно их тоже перевести в цикл для единообразия — без блокера.
- UI bind-матрицы в админке (как у FreePBX через
sipsettings) — overengineering для нашего use case. Достаточно «авто-режима» + power-user override через additional_params (если механизм есть, иначе отдельный фича-запрос).
5.5 Что нужно дополнительно проверить grep'ом перед коммитом
# Использует ли что-то в Core/Extensions имя 'transport-udp' явно?
grep -rn "transport=transport-udp\b\|'transport-udp'" Core/ Extensions/ 2>/dev/null
# Стучит ли что-то в asterisk через sip:user@127.0.0.1 (loopback SIP)?
grep -rEn 'sip:[^@]+@127\.0\.0\.1' Core/ Extensions/ 2>/dev/null
Первое — на случай хардкода имени транспорта в endpoint/aor-генерации. Если есть — либо динамически использовать имя primary-LAN-транспорта, либо принудительно сохранить имя transport-udp для primary LAN (что и сделано в эскизе выше).
Второе — на случай локальных SIP-клиентов внутри PBX (ModuleAutoCalls / ModuleAmoCrm / Callback / встроенные демоны). Если такие есть — добавить [transport-udp-lo] bind=127.0.0.1:5060 как страховку.
6. Подтверждение со стороны протокола — phones do «first response wins»
В обсуждении возникал тезис: «телефоны должны ждать правильный ответ, в сети может быть много серверов». Это утверждение фактически неверно для Yealink/Snom/Fanvil PnP.
Источники:
Возможный источник путаницы у разработчика — поведение SIP REGISTER (где 401 нормален и триггерит retry-with-auth) или DHCP (где клиент собирает несколько OFFER'ов). Ни одна из этих аналогий не применима к Yealink-style multicast PnP SUBSCRIBE — там реализована именно first-wins логика.
Косвенное подтверждение эмпирикой
В нашей лабораторной сети есть .215 (FreePBX 15 / Asterisk 16), который независимо отвечает 401 Unauthorized на multicast PnP за ~1 мс. Если бы телефоны умели «ждать правильный ответ» — у Vladimir всё бы давно работало, поскольку наш worker всё равно отвечает позже. У Vladimir не работает. То есть телефон действительно берёт первый ответ и забывает остальное — совпадает с документацией.
7. Что делает FreePBX (для контекста)
Проинспектирован /usr/local/bin/pnp_server (commercial, Sangoma EULA, Python) на 172.16.32.142 (FreePBX 15.0.38):
MCAST_GRP = '224.0.1.75'
MCAST_PORT = 60000 # ← НЕ 5060
sock.bind(('', MCAST_PORT))
sng = re.compile(r'Event: ua-profile;.+vendor="Sangoma"')
if not sng.search(packet): continue # только Sangoma S-phones
netstat:
udp 0.0.0.0:5060 asterisk ← обычный SIP на 0.0.0.0 (как у нас)
udp 0.0.0.0:60000 python ← их PnP-демон, на ПРИВАТНОМ порту
То есть FreePBX полностью обходит конфликт, потому что их Sangoma S-series phones прошиты слать PnP-SUBSCRIBE на нестандартный порт 60000. Asterisk слушает только 5060, не пересекается. Для 3rd-party телефонов (Yealink/Snom/Fanvil) у них нет multicast-PnP вообще — рекомендуется DHCP Option 66 или ручной URL.
MikoPBX, в отличие от FreePBX, реально поддерживает multicast-PnP для 3rd-party телефонов — это наше преимущество. Цена этого — необходимость решить race-condition, которую Sangoma себе позволила избежать через закрытый протокол на закрытом порту для своих же трубок. Мы не можем переиспользовать их трюк, потому что firmware Yealink/Snom/Fanvil жёстко зашита на 5060 и не настраивается.
8. Объём работы и риски
| Аспект |
Оценка |
| Объём кода (PR-1) |
~10 LOC в SIPConf::generateTransports, нулевой риск |
| Объём кода (PR-2) |
~60-80 LOC в SIPConf::generateTransports + триггер в save-hook LanInterfaces |
| Совместимость с endpoint'ами |
сохраняется через имя transport-udp для primary LAN |
| Регрессии в single-LAN bare-metal |
минимальные (если нет 127.0.0.1-SIP-клиентов) |
| Регрессии в multi-LAN |
управляются через поле LanInterfaces.internet для NAT-bind |
| Регрессии в Docker |
улучшение: подход естественно работает с container bridge IP |
| DHCP / IP-mobility |
РЕГРЕСС без триггера в LanInterfaces save-hook — обязателен |
| IPv6 |
не трогаем (race на v6 не существует) |
9. Затрагиваемые файлы
Core (требует правки):
Core/src/Core/Asterisk/Configs/SIPConf.php:947-989 — generateTransports()
Core/src/Common/Models/LanInterfaces.php — добавить save-hook на ipaddr/ipv6addr/disabled
Core/src/Core/System/Configs/... — точка, которая дёргает core restart now после regen pjsip.conf (вероятно есть в SystemControl/PBXConfModulesProvider)
ModuleAutoprovision (read-only зависимость) — никаких правок не требуется. Воркер работает корректно, проблема целиком на стороне Core/pjsip конфигурации.
10. Дополнительные материалы
- Полное описание расследования и команды воспроизведения: секция «Diagnostics on a live MikoPBX» в
Extensions/ModuleAutoprovision/CLAUDE.md (этого репо).
- Локально написанный документ-исследование:
.claude/worktrees/claude-md-diagnostics/TASK_pnp_pjsip_race.md.
- Python-эмулятор PnP-телефона для регрессионных тестов (без зависимости от sipp):
/root/yealink_pnp.py на 172.16.32.177 (AlmaLinux 9.7, тот же сегмент, что и тест-PBX 172.16.32.94). Скрипт умеет настраивать User-Agent / vendor / model, слушает все ответы с микросекундными метками, ловит race-условие end-to-end.
CC: @boffart — буду благодарен за консультацию по точке-регенератору в SystemControl/PBXConfModulesProvider (где сейчас триггерится re-gen pjsip.conf при сохранении SIP-настроек, чтобы туда же зацепить триггер на смену IP интерфейса).
TL;DR
ModuleAutoprovision(PnP-провижн для Yealink/Snom/Fanvil/Grandstream) не работает на части машин клиентов: телефон шлёт multicastSUBSCRIBEна224.0.1.75:5060, наш PHP-воркер ловит и формируетNOTIFYс URL конфигурации, но Asterisk (pjsip) наbind=0.0.0.0:5060успевает ответить первым —401 Unauthorizedлибо400 Bad Request. Телефон, по дизайну протокола, доверяет первому пришедшему ответу — то есть берёт ответ от pjsip и игнорирует наш правильный NOTIFY. PnP молча отваливается.Проблема архитектурная и решается только в Core, в
SIPConf::generateTransports(). Подробности, эмпирика, обоснование выбора решения — ниже.Сообщает клиент @shaxov92 (Yealink T19 на MikoPBX 2026.1.223), воспроизведено локально на 172.16.32.94 (MikoPBX 2026.2.89-dev + Asterisk 22.8.2).
1. Симптом, на стороне клиента
Дамп
tcpdumpу @shaxov92 (Yealink T19, FW 53.84.0.140, MikoPBX 2026.1.223, ModuleAutoprovision 1.66):То есть Asterisk шлёт auth challenge на multicast PnP-SUBSCRIBE. Наш
NOTIFYприходит позже (если приходит) — телефон уже разорвал диалог по 401 и URL конфигурации не подхватывает.2. Корневая причина — Linux kernel hook order
Воркер слушает multicast через
SOCK_RAW+MCAST_JOIN_GROUP:MCAST_JOIN_GROUPделает host-level IGMP-членство на интерфейсе. Это нужно физически — без членства NIC отбрасывает multicast-кадры на L2-уровне, kernel вообще ничего не видит (мы проверили:pkill worker→/proc/net/igmpтеряет запись4B0100E0→ multicast SUBSCRIBE до Asterisk не доходит).Но host-level membership имеет побочный эффект: kernel начинает доставлять multicast-пакеты всем сокетам с подходящим bind (
__udp4_lib_mcast_deliverвnet/ipv4/udp.cобходит весь hash chain). Под это попадает и pjsip-UDP, который у нас всегда биндится на wildcard:Wildcard
0.0.0.0:5060матчится под multicast-цель224.0.1.75:5060→ kernel доставляет пакет в pjsip → pjsip генерит ответ в соответствии со своей endpoint-логикой (401,400,489— зависит от конкретногоendpoint_identifier_order/ наличия anonymous endpoint).То есть наш собственный воркер невольно подставляет pjsip: мы регистрируем host как члена multicast-группы, а pjsip пользуется этим как «бесплатным» подписчиком на ту же группу.
Порядок hook'ов в ядре
Для полноты, путь IPv4-multicast UDP в Linux:
Из этого следует — детерминированно, не зависит от прошивки/дистрибутива:
(dst-ip, dst-port), получают копию пакета.INPUT … 224.0.1.75 udp/5060 -j DROPрежет оба (и pjsip, и наш raw-сокет — это эмпирически подтверждено, см. п.3).socket_create(): Argument #1 ($domain) must be one of AF_UNIX, AF_INET6, or AF_INET— проверено на текущей сборке PHP 8.3 в MikoPBX).3. Эмпирические данные (172.16.32.94 — MikoPBX, 172.16.32.177 — AlmaLinux-эмулятор фантома Yealink)
bind=0.0.0.0, worker живbind=0.0.0.0, worker мёртв (kill-loop)bind=172.16.32.94, worker живINPUT … 224.0.1.75 udp/5060 -j DROP, worker живВсе измерения с
tcpdump -i any -tttt+ python-эмулятор SUBSCRIBE с реальнымEvent: ua-profile;profile-type="device";vendor="Yealink";.... Полный набор команд воспроизведения — в.claude/worktrees/claude-md-diagnostics/TASK_pnp_pjsip_race.mdи обновлённой секции «Diagnostics on a live MikoPBX» вCLAUDE.md.Третья строка таблицы — мы временно меняли
bind=0.0.0.0:5060наbind=172.16.32.94:5060через прямую правку/etc/asterisk/pjsip.conf+core restart now, прогоняли тест, восстанавливали (md5 совпал). Это и есть прототип предлагаемого фикса.4. Что не работает (рассмотрено и отклонено)
INPUT -d 224.0.1.75 -p udp --dport 5060 -j DROPautoprovision-pnpтишина.AF_PACKET. Потребует C-helper / pcap-extension в Core build — несоразмерно объёмная правка.489,481,400) — поведение прошивок Yealink/Snom/Fanvil на эти ответы vendor-specific, гарантии нет.match_request_uriтребует Asterisk 18+.[transport-udp-mcast] bind=224.0.1.75:5060рядом с основным[transport-udp] bind=0.0.0.0:50605. Предлагаемое решение
Per-interface unicast bind для
transport-udpвSIPConf::generateTransports(). Эмпирически проверено на 172.16.32.94, работает чисто.5.1 Минимальная версия (PR-1, безопасный, не решает race, но устраняет «грабли»)
Сразу в 1.67 — гигиенический патч из трёх строчек в
SIPConf::generateTransports()для всех 4 транспортов:Источник: так делает FreePBX (
/var/www/html/admin/modules/core/functions.inc/drivers/PJSip.class.php:1001,1006-1007). Никаких функциональных изменений — только явно признаём то, что мы и так уже видели на стенде: pjsip не делает hot-reload bind/transport, попытка черезpjsip reloadилиmodule reload res_pjsip.soтихо игнорируется. Применение нового bind требуетcore restart nowлибо полного unload/loadres_pjsip.so(которое падает на зависимостях).allow_reload=noснимает иллюзию, что reload-достаточно.Регресс-риск: ноль. ~10 LOC.
5.2 Основной фикс race-condition (PR-2, может ждать 1.68)
В
Core/src/Core/Asterisk/Configs/SIPConf.php:947-989(методgenerateTransports) — превратить блок[transport-udp]в цикл поNetwork::getEnabledLanInterfaces():5.3 Обязательный сопутствующий триггер
Сейчас, при смене IP интерфейса (DHCP renewal, ручная переконфигурация), pjsip продолжает слушать старый IP до full restart Asterisk. С
bind=0.0.0.0это незаметно (wildcard ловит новый IP автоматически). После фикса — становится регрессом, ломающим DHCP-сценарии.Митигация: в save-hook модели
LanInterfaces(либо в имеющемсяSystemControl::reloadAsterisk/ эквивалент) при измененииipaddrинтерфейса сdisabled=0запускать:/etc/asterisk/pjsip.confчерезSIPConf::reConfigure.asterisk -rx "core restart now"(а неpjsip reload— он не пересоздаст транспортные сокеты, мы это эмпирически подтвердили).Это обязательная часть PR-2. Без неё фикс ломает мобильные сценарии без видимого симптома (телефоны просто перестают регистрироваться после смены IP, диагностика непростая).
5.4 Что не делаем в этом PR
224.0.1.75. Соответственно, kernel не доставляет multicast в pjsip-IPv6 (host не member ни одной IPv6 SIP-multicast группы). Симметричный per-interface bind для IPv6 — отдельный hygiene-PR в будущем.bind=0.0.0.0:5062и:8089). Они unicast-only, не страдают от race. Опционально можно их тоже перевести в цикл для единообразия — без блокера.sipsettings) — overengineering для нашего use case. Достаточно «авто-режима» + power-user override черезadditional_params(если механизм есть, иначе отдельный фича-запрос).5.5 Что нужно дополнительно проверить grep'ом перед коммитом
Первое — на случай хардкода имени транспорта в endpoint/aor-генерации. Если есть — либо динамически использовать имя primary-LAN-транспорта, либо принудительно сохранить имя
transport-udpдля primary LAN (что и сделано в эскизе выше).Второе — на случай локальных SIP-клиентов внутри PBX (ModuleAutoCalls / ModuleAmoCrm / Callback / встроенные демоны). Если такие есть — добавить
[transport-udp-lo] bind=127.0.0.1:5060как страховку.6. Подтверждение со стороны протокола — phones do «first response wins»
В обсуждении возникал тезис: «телефоны должны ждать правильный ответ, в сети может быть много серверов». Это утверждение фактически неверно для Yealink/Snom/Fanvil PnP.
Источники:
200 OK+NOTIFYот первого ответившего settings-сервера, без disambiguation — https://service.snom.com/spaces/wiki/pages/234336488/What+does+-+PnP+config+-+on+the+-+Advanced+Page+-+doВозможный источник путаницы у разработчика — поведение SIP REGISTER (где
401нормален и триггерит retry-with-auth) или DHCP (где клиент собирает несколько OFFER'ов). Ни одна из этих аналогий не применима к Yealink-style multicast PnP SUBSCRIBE — там реализована именно first-wins логика.Косвенное подтверждение эмпирикой
В нашей лабораторной сети есть
.215(FreePBX 15 / Asterisk 16), который независимо отвечает401 Unauthorizedна multicast PnP за ~1 мс. Если бы телефоны умели «ждать правильный ответ» — у Vladimir всё бы давно работало, поскольку наш worker всё равно отвечает позже. У Vladimir не работает. То есть телефон действительно берёт первый ответ и забывает остальное — совпадает с документацией.7. Что делает FreePBX (для контекста)
Проинспектирован
/usr/local/bin/pnp_server(commercial, Sangoma EULA, Python) на 172.16.32.142 (FreePBX 15.0.38):netstat:То есть FreePBX полностью обходит конфликт, потому что их Sangoma S-series phones прошиты слать PnP-SUBSCRIBE на нестандартный порт
60000. Asterisk слушает только5060, не пересекается. Для 3rd-party телефонов (Yealink/Snom/Fanvil) у них нет multicast-PnP вообще — рекомендуется DHCP Option 66 или ручной URL.MikoPBX, в отличие от FreePBX, реально поддерживает multicast-PnP для 3rd-party телефонов — это наше преимущество. Цена этого — необходимость решить race-condition, которую Sangoma себе позволила избежать через закрытый протокол на закрытом порту для своих же трубок. Мы не можем переиспользовать их трюк, потому что firmware Yealink/Snom/Fanvil жёстко зашита на 5060 и не настраивается.
8. Объём работы и риски
SIPConf::generateTransports, нулевой рискSIPConf::generateTransports+ триггер в save-hookLanInterfacestransport-udpдля primary LANLanInterfaces.internetдля NAT-bind9. Затрагиваемые файлы
Core (требует правки):
Core/src/Core/Asterisk/Configs/SIPConf.php:947-989—generateTransports()Core/src/Common/Models/LanInterfaces.php— добавить save-hook наipaddr/ipv6addr/disabledCore/src/Core/System/Configs/...— точка, которая дёргаетcore restart nowпосле regen pjsip.conf (вероятно есть вSystemControl/PBXConfModulesProvider)ModuleAutoprovision (read-only зависимость) — никаких правок не требуется. Воркер работает корректно, проблема целиком на стороне Core/pjsip конфигурации.
10. Дополнительные материалы
Extensions/ModuleAutoprovision/CLAUDE.md(этого репо)..claude/worktrees/claude-md-diagnostics/TASK_pnp_pjsip_race.md./root/yealink_pnp.pyна 172.16.32.177 (AlmaLinux 9.7, тот же сегмент, что и тест-PBX 172.16.32.94). Скрипт умеет настраивать User-Agent / vendor / model, слушает все ответы с микросекундными метками, ловит race-условие end-to-end.CC: @boffart — буду благодарен за консультацию по точке-регенератору в
SystemControl/PBXConfModulesProvider(где сейчас триггерится re-gen pjsip.conf при сохранении SIP-настроек, чтобы туда же зацепить триггер на смену IP интерфейса).