Skip to content

Race condition: pjsip перехватывает multicast PnP SUBSCRIBE раньше worker'а (требуется per-interface bind в Core/SIPConf) #17

@jorikfon

Description

@jorikfon

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 запускать:

  1. Регенерацию /etc/asterisk/pjsip.conf через SIPConf::reConfigure.
  2. 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-989generateTransports()
  • 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 интерфейса).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions