Как стать автором
Обновить

Привычный ужас в SIP, или о том, как не надо проектировать сетевые протоколы. Часть 1 — синтаксис и морфология

Время на прочтение12 мин
Количество просмотров12K

Здравствуйте, меня зовут Валентин, и я задолбался. Нет-нет, вы всё ещё на Хабре.

Все технологии телефонии ужасны.

Большинство технологий разработки IETF ужасны. Может, не ужасны-ужасны, как ISO…

Когда они смешиваются… ну вы в курсе. Или ещё нет? Получается SIP.


Это пост ворчания, техническая суть которого может быть полезна паре сотен человек. Но, to grumble is human.





Начнём с синтаксиса


HTTP заголовок видели все. Ну, большинство. Кто не видел — может посмотреть на заголовок почтового письма (email). Принципы взяты оттуда, НО...



Я буду брать примеры из стандартных RFC, чтобы не влияла местная специфика. Типичное сообщение выглядит в таком стиле:



INVITE sip:bob@biloxi.example.com SIP/2.0
Via: SIP/2.0/TCP client.atlanta.example.com:5060;branch=z9hG4bK74bf9
Max-Forwards: 70
From: Alice <sip:alice@atlanta.example.com>;tag=9fxced76sl
To: Bob <sip:bob@biloxi.example.com>
Call-ID: 3848276298220188511@atlanta.example.com
CSeq: 1 INVITE
Contact: <sip:alice@client.atlanta.example.com;transport=tcp>
Content-Type: application/sdp
Content-Length: 151

v=0
o=alice 2890844526 2890844526 IN IP4 client.atlanta.example.com
s=-
c=IN IP4 192.0.2.101
a=sendrecv
t=0 0
m=audio 49172 RTP/AVP 0
a=rtpmap:0 PCMU/8000

Alice инициирует звонок к Bob (полные адреса указаны в виде user@domain), предлагая аудио в кодеке PCMU (G.711u) и сообщая свои адреса (хост: порт) для сигнализации и для медии.

Пойдём вглубь…

Параметры, параметры и параметры


У нас есть address parameters, URI parameters и user parameters. Не то чтобы это был необходимый набор для работы, но если начал, становится трудно остановиться… Все три отделяются точками с запятой, ограничения на синтаксис у всех немного разные. Смотрите не перепутайте. Кстати, чем отличается адрес от URI, тоже не каждому сразу получится объяснить. Адрес (определение формально не встречается в спеках, но постоянно используется на практике) — это содержимое всяких From/To/Contact/Route (один элемент, где их несколько), URI с параметрами и (возможно) именем… Ну поехали:



To: sip:bob@example.com
тут всё просто — схема, домен и юзер.


To: sip:bob@example.com;xyz=123
xyz=123 — параметр адреса. Но: если URI должен присутствовать сам по себе, как в первой строке запроса:

INVITE sip:bob@example.com;xyz=123
то xyz=123 будет уже параметром URI.

To: <sip:bob@example.com>;xyz=123
To: "Bob" <sip:bob@example.com>;xyz=123
xyz=123 — параметр адреса.

To: <sip:bob@example.com;xyz=123>;xyz=456
xyz=123 — параметр URI.
xyz=456 — параметр адреса.

To: sip:bob;rn=alice@example.com;xyz=987
Так нельзя. Странно, да? Хотя если URI там, где нужен именно URI (в request line), то можно:
INVITE sip:bob;rn=alice@example.com;xyz=987 SIP/2.0

Надо заключить в угловые скобки:

To: <sip:bob;rn=alice@example.com;xyz=987>;xyz=123
rn=alice — параметр юзера.
xyz=987 — параметр URI.
xyz=123 — параметр адреса.


Кстати, во всех допускаются символы за пределами очевидного набора типа [A-Za-z0-9] и ещё некоторого набора неожиданно допущенных, но по-разному. В user и в URI надо квотить недопустимое их кодами через %XX, а в адресе — через quoted string, и никак иначе. Поэтому параметр со значением a%\b будет передан:


To: <sip:bob;xyz=a%25%5Cb@example.com;xyz=a%25%5Cb>;xyz="a%\\b"

В спеке на синтаксис, обобщение для параметра URI зовётся other-param, а параметра адреса — generic-param. Смотрите не перепутайте, правила для них заметно разные. Кстати, user parameters это другая спека (точнее, произведение двух — RFC4694 + RFC3398).


Что может быть значением параметра URI? Что угодно, только надо его единообразно заквотить (через %XX), дальше это набор октетов (UTF-8, если в SIP). Примеры были выше.


Что может быть значением параметра адреса? token, quoted string или host. Кстати, token — это не token в смысле грамматического разбора. Это особый token, которого лично зовут token. Но при этом с маленькой буквы, потому что он самый скромный token.


To: <sip:bob@example.com>;xyz=127.0.0.1

127.0.0.1 это host или token? Допустимы оба. Должен ли я считать, что это host?

To: <sip:bob@example.com>;xyz=[fe80::1]
To: <sip:bob@example.com>;xyz="[fe80::1]"

Значения одинаковы. Но в первом случае это host подтипа IPv6reference,
во втором — quoted string. Если я прокси и передаю параметр через себя, я должен сохранить, чем он был — восстановление по содержимому не поможет.


Кстати, к предыдущему примеру:



To: <sip:bob@example.com>;xyz="127.0.0.1"

это не host и не token. Но если объекту типа address скажут назначить параметр со значением "127.0.0.1" — какой тип выбрать? Ах ну да, такая установка уже должна указывать тип...


Вместе с тем, что если это token, то регистр букв не имеет значение, а если quoted-string, то имеет; а если host, то снова не имеет… — для адекватности одновременно обработки и сохранения исходного значения (про регистр подробнее ниже) я должен хранить:


1) Исходную форму как октетную или unicode строку — для точного воспроизведения;
2) Приведённую форму — для сравнения;
3) Признак, какого типа был параметр на входе (насколько это вообще восстановимо, не зная по названию параметра, чем он должен быть… хотя годится обобщение типа — любой host кроме IPv6reference может храниться как token… там ещё есть несколько подобных тонкостей).


Но в To допускается только один адрес. А в Contact — несколько. Есть некоторые другие поля заголовка, где тоже можно, но пока пусть например будет слонёнок Contact:

Contact: sip:alice@example.com, sip:bob@example.com

Да, можно.


Contact: <sip:alice@example.com>, sip:bob@example.com, <sip:mallory@example.com>

Тоже можно.


Contact: sip:alice,bob@example.com

Нельзя. URL с запятой должен быть заключён в <>. Распарситься не должно: после запятой нет схемы URL.


Contact: sip:alice, bob bob bob <bob@example.com>

Уже можно. Хотя это надо минимум три синяка от граблей, чтобы сразу видеть, что alice — уже не юзер, а домен.


Contact: sip:alice,bob@example.com, sip:mallory@example.com

Нельзя. URL с запятой внутри поля заголовка конструкции «адрес» должен быть заключён в <>.


Contact: <sip:alice,bob@example.com>, sip:mallory@example.com

Уже можно. При этом второй адрес заключать не обязательно.


Хорошо, а это что тогда?


Contact: sip:alice,sip:bob@example.com

Это два адреса в контактах: sip:alice (alice это хост, user отсутствует целиком) и sip:bob@example.com. Но:


Contact: <sip:alice,sip:bob@example.com>

Это один адрес с user=«alice,sip» и паролем bob. Да-да, запятая в user возможна и её можно не квотить.


Это я ещё не вспоминал, что впереди может быть display-name, а позади — адресные параметры:


Contact: sip user <sip:alice>, "The Bob" <sip:bob@example.com>;
foo="The Bob, Almighty and Superb <sip:bob>", <tel:alice;xyz=987>


И снова здравствуйте, меня зовут Валентин. («Здравствуй, Валентин» — ответил нестройный хор.) Я сделал реализацию разбора SIP регулярными выражениями, и нет, я не стыжусь этого. У меня нет проблем, которые нельзя так решить: в грамматике нет рекурсии (максимум — есть списки), и, к счастью, это единственное, что там хорошее. Если бы была рекурсия — разбор был бы ужасен-ужасен. А так, он просто ужасен:


>>> len(SipSyntax.re_addr)
2847

Нет, мне не страшно. Потому что эти выражения формируются из отдельных частей:


re_addr = "(?:%s|%s)%s?" % (re_name_addr, re_addr_spec_short, re_gen_params_wsc)

и на каждом этапе я могу проверить качество каждого выражения и соответствие правилам комбинирования. Кстати, некоторые мелочи, которые никогда за почти 20 лет существования продуктового стека не понадобились (типа возможности absoluteURI), тут не реализованы. С ними было бы ещё в полтора раза больше, наверно.


Но почему таки регулярные выражения? — спросит отдельный робкий голос. Ответ: а потому что Python.


Я сравниваю один и тот же разбор URI на Python через regexps, Java через regexps и Java через пропуск по классам символов. Сравнение эффективности на одной и той же железке (Ryzen5):


CPython + regexps        - 14K/sec
PyPy    + regexps        - 130K/sec
Java    + regexps        - 270K/sec
Java    + symbol classes - 1800K/sec
PyPy    + symbol classes - 3K/sec

Всё потому, что в Python движок регулярных выражений — написан на C, а обходить его означает выходить на уровень интерпретатора, который страшно медленный. Предкомпилированные (на этапе загрузки модулей парсинга) выражения превращаются в аккуратные таблички в памяти — это хуже, чем выхлоп какого-нибудь re2c, но по ним бегает неплохо оптимизированный код матчинга. В Java вроде JIT, но движок регулярных выражений написан на самой Java, и JIT ему не очень помогает, когда логика состоит из сплошных переходов с лукапами по табличкам, которые этим JITʼом не перерабатываются. На чём-то напрямую компилируемом цифры могут быть ещё вкуснее… нет, я не буду впадать в этот флейм. PyPy заметно помогает, но есть порог, за которым кончается и его умение (а цифры говорят, что >90% накладных расходов CPython это работа со структурами Python, а не собственно управление памятью, парсинг и т.п.)


Что такое лукап по классам символов — это в таком духе:


        while (pos < limit && (b = source[pos]) >= 0 &&
                SipSyntax.userinfo_allowed[b] != 0)
        {
            ++pos;
        }

табличка userinfo_allowed тщательно выверена руками и подкреплена юнит-тестом.


(Меня удивила цифра PyPy, но, может, это я оказался такой криворукий. Но на CPython я даже не пытаюсь это делать… страашно. Ладно, может, попробуем, как сады зацветут.)


А ещё есть проблема '#'. По RFC, такое не разрешено:


INVITE sip:123#456@example.com SIP/2.0
From: <sip:123#789@example.com>; tag=from1


но не только лишь все (tm), начиная с Cisco, это игнорируют. Поэтому почти сколько существует наш продукт, почти столько и существует параметр получателя url_hash_safe, который говорит рисовать #, а не %23 (а %23 многие из них не понимают). Но некоторые наоборот, не понимают, если ставить '#'. Поэтому нужна регуляция по конкретному ремотному участнику — определять, кому таторы, а кому ляторы. Последний раз я подсказывал техподдержке включить этот параметр прошлой осенью. Наверно, эта проблема будет жить вечно. Ах да, '*' — ещё один частый символ в этих последовательностях — разрешён явно и проблемы не вызывает. Неаккуратненько-с.


В RFC3261 есть роскошное примечание:


The Contact, From, and To header fields contain a URI. If the URI contains a comma, question mark or semicolon, the URI MUST be enclosed in angle brackets (< and >). Any URI parameters are contained within these brackets. If the URI is not enclosed in angle brackets, any semicolon-delimited parameters are header-parameters, not URI parameters.

Спасибо, дорогая партия, за наше счастливое детство. У меня вопрос: а зачем вообще было разрешать вариант без угловых скобок? Ах да, забыл: в Route и Record-Route угловые скобки обязательны. В From, To, Contact… — нет. Картина Репина.



Почему не готовый движок парсинга?


Если упоминается Java, кто-то обязательно спросит — почему не ANTLR?


Я пробовал применить ANTLR. Нет, конечно, это можно. Забитый молотком шуруп держится лучше, чем вкрученный отвёрткой гвоздь. В linphone его применяют. Но уже там видны недочёты.


Ему нельзя снаружи управлять контекстом для лексического анализатора, а это критично. Есть несколько разных стартовых контекстов: как минимум: первая строка сообщения, URI, адрес, ряд специфических заголовков со своими правилами (Call-Id, Authorization...)


Можно было бы справиться с semantic rules «на месте», но:


  • Каждый парсер стартует со своим лексером.
  • Лексер не может получать указание по контексту от парсера, он должен определять это сам.
  • Контекстов очень много.

Часто однозначного решения нет. Начало адреса — может присутствовать или схема с ':' (включать ли двоеточие в токен лексера?), или display-name как последовательность token (это не тот токен, это другой токен, который token в грамматике SIP). После схемы в SIP-URI — или хост, или username, и различить их можно только по тому, что раньше встретится после него (может, заметно после него) — '@', ';', '?' или EOI. Не так предсказал — откат назад на другую гипотезу.


Начальных контекстов — как минимум: начало адреса (одиночного или множественного поля заголовка типа From, To, Contact, Route, Record-Route...); начало URI (например, в первой строке SIP запроса). Есть ещё несколько специфических контекстов — для авторизационных заголовков, например — там целые URI могут находиться в кавычках. В результате, получается сплошное дублирование. Грамматики лексеров не могут включать другие грамматики лексеров (почему?) В результате чтобы избежать копипастинга надо генерировать грамматики отдельным макропроцессором. Ну спасибо...


Альтернативно, можно было бы на уровне лексера определить каждый символ, а выше — собирать их уже в грамматические конструкции. Исключение даётся только для quoted-string (и то за пределами авторизационных заголовков). Но производительность падает на порядки, реализация на Java становится тормознее реализации на Python+regexps.


Поэтому доступные реализации невозможно читать без ящика водки. Виски не подойдёт: он для добавления положительных эмоций, а не гашения отрицательных. Если бы не требование менять контексты лексера из самого лексера, было бы ещё подъёмно (LL(∞) превратился бы в цивилизованный PEG), но не как сейчас.


Вместо регулярных выражений можно использовать классы символов. И на компилируемом языке это быстрее (ускорение раз в 5 я получаю без оптимизационных ухищрений). Но не на Python: его движок регулярных выражений на C, а поиск по строке будет чудовищно дорог.


В целом, мир грамматики IETF выглядит сущностью, принципиально противоположной тому, что рекламируется, например, в Dragon Book. Возможно, для него нужна своя теория, которая будет заметно отличаться от знаменитых LL(k), LR(k) и прочих. Но я такого не видел.


И ложка мёда:


А знаете, что хорошо по сравнению с RFC822, например? Границы строк чётко определены. Кошмариков типа "\\\r\n", которое неверно разбирали 99% почтовых парсеров, уже нет. Поэтому я могу (и даже обязан) сначала опознать границы строк, по ним — полей заголовка, и тогда каждый подавать на разбор — уже по своему вкусу (а скорее всего вообще не подавать те, что не нужно).


За 17 лет (1982->1999) IETF кое-чему научился. Жаль, что это так медленно происходит.



Морфология


У нас в каждом сообщении нужно посылать From и To. На INVITE выше приходит ответ, например:


SIP/2.0 100 Trying
Via: SIP/2.0/TCP client.atlanta.example.com:5060;branch=z9hG4bK74bf9
    ;received=192.0.2.101
From: Alice <sip:alice@atlanta.example.com>;tag=9fxced76sl
To: Bob <sip:bob@biloxi.example.com>
Call-ID: 3848276298220188511@atlanta.example.com
CSeq: 1 INVITE
Contact: <sip:bob@client.biloxi.example.com;transport=tcp>
Content-Length: 0

Реально они нужны только в одном виде сообщений — в начальном для установления диалога (или в отдельной транзакции). Дальше их можно тупо выкидывать, от этого ничего не поменяется: на них ничего не завязывается и никто не парсит, хотя пропускать их запрещено. Матчинг ответов на транзакции происходит по h_Via.branch. Матчинг ответов внутри диалога происходит по тегам диалога. И снова, смотрите не перепутайте. Я сам путаюсь, когда через несколько месяцев не-общения с этим всем возвращаюсь к тематике.


Так зачем же нужны эти From и To? А затем, что там теги.


From: Alice <sip:alice@atlanta.example.com>;tag=9fxced76sl
To: Bob <sip:bob@biloxi.example.com>;tag=skfhjskdkfsd

Ах да, я ж забыл упомянуть теги:


У нас есть call-id и есть два тега. Вместе они образуют полный идентификатор диалога (ассоциация между двумя конечными участниками). Сообщение внутри диалога (у него два тега) определяет модификацию установленного звонка, а вне диалога (только один тег) — новый звонок. (Я упростил всё, что можно, но для первого описания пойдёт.)


Call-id имеет особые, ни на что остальное в SIP не похожие, правила допуска и сравнения. Он регистрозависим (в отличие от тегов диалога), но об этом ниже. У него свой набор допустимых символов. Call-id неуникален, его совпадение у соседних звонков — норма и закономерность (при форкинге диалога, когда на один запрос отвечают несколько удалённых участников). В отличие от, теги диалога уникальны, требуется хотя бы 32 бита случайности при их генерации, больше — лучше. На самом деле после генерации тегов call-id вообще не нужен, у него роль… мнэээ… в рамках стандартных использований по RFC у него роли вообще нет, кроме как путаться под ногами, всё работало бы и без него. А вот для B2BUA, как у нас, call-id начинает выполнять особую функцию связи между звонками на разных сторонах одного B2BUA. Но B2BUA не определён ни одним RFC даже в ранге «informational»; это такое странное понятие, на которое ссылаются в нескольких RFC, но определено в лучшем случае неформально.


Так почему теги не в отдельных полях заголовка? Вы не найдёте обоснованного ответа на этот вопрос: это уже закон жизни, который тайна веков. Но зато ради них приходится полностью парсить целые From и To, с учётом всех тонкостей по содержанию (см. выше про адресные поля).


Cинтаксис возвращается: регистр букв


По непонятной прихоти call-id регистрозависим, а теги — нет. Точнее, «так в книге написано» (tm), что они регистронезависимы. Реально же:
1) Есть UA (user agents — железные или программные телефоны), которые воспринимают посылки в диалоге только если буквы в теге в том же регистре, что они посылают (если это Abc, то и надо прислать им Abc).
2) Есть UA, которые переделывают теги под себя, и если им прислать abc — они ответят ABC. Или наоборот, прислали ABC — а они отвечают abc.


И с обоими надо работать. Отказывать просто потому, что они нарушают… ну в основном нельзя. Потому что всё равно не исправят — большинство из них железные, и авторы прошивки просто не поймут, чего от них хотят. Не знаю, зачем производители железных UA нанимают таукитян… или сентинельцев? Сложно разобрать, но проверено, что планета у них какая-то другая.


Какой вариант работает в итоге? Тег надо хранить в двух вариантах: приведённый к конкретному регистру — для сравнения, и неприведённый — для посылки другому участнику. Где-то мы это уже слышали?


И то же оказалось… привет, история в Git… с transport параметром URI. Потому что мы добрые, и если чужой телефон или SBC настаивает, что должно быть transport=UDP, а не transport=udp, то легче согласиться, чем спорить. Это карма авторов чисто софтового продукта: легче адаптировать софт, чем даже прошивку железного аппарата (а тем более само железо).


Но это были цветочки, основной же вопрос — зачем вообще эта регистронезависимость?


Стандартные аргументы в пользу регистронезависимости: облегчение человекочитаемости. Это миф: её путают с человекоозвучиваемостью. Регистр не должен быть важен, например, в имени человека (хотя и тут могут быть тонкости). Но я не знаю, в каком случае с экрана будет читаться что-то в шрифтах, которые не различают регистр. И совсем не понимаю, почему и зачем различие регистра считается важнее, чем 100500 других различий.


Этот тезис можно обсудить в применении, например, к файловым системам — отдельно (почему такие FS различают «foo.docx», «foo.docx », «foо.docx» и «fo​o.docx» (кто увидит разницу?), но не различают «foo.docx» и «Foo.docx»?), и что будет в случае İı (см. Turkey test), но сейчас речь не о них, а о SIP на простом беспроблемном ASCII.


Вы можете написать:


<code>
From: <sip:123@foobar>;tag=mamarama
</code>

но вы можете также написать:


<code>
fRoM      :
  <sip:123@foobaR>;TaG=mamaRAMa
</code>

Все воспринимающие должны понять это одинаково. Но чего этого стоит? Обратимся к коду одной из самых быстрых реализаций — Kamailio, в девичестве Sip Express Router.


/*! \brief macros from parse_hname2.c */
#define READ(val) \
(*(val + 0) + (*(val + 1) << 8) + (*(val + 2) << 16) + (*(val + 3) << 24))

#define LOWER_DWORD(d) ((d) | 0x20202020)

...

#define _cont_ 0x746e6f63
...

...
                                // например возьмём этот кусок
                                if ((LOWER_DWORD(READ(c)) == _cont_) &&
                                        (LOWER_DWORD(READ(c+4)) == _ent__) &&
                                        (LOWER_DWORD(READ(c+8)) == _type_)
                                ) {
                                        /* Content-Type HF found */

Ну, это хорошо работает на C. (Clang умудрился свернуть READ в просто 32-битное чтение из памяти. GCC этого не сумел. Ну не так страшно.) Уже на Java надо финтить, но хотя бы есть ByteBuffer. На интерпретаторах вроде Python, и на Java на строках, это превращается в чудовищную затрату или времени, или памяти, или обоих сразу.


Но и этот детект помогает только в очень ограниченной роли — быстрый поиск по первым четвёркам символов (а ещё есть альтернативные имена полей — f вместо from, i вместо call-id; пробелы до и после ':' в неограниченном количестве; и так далее). А как только мы выходим за пределы средств с прямым изменением в буферах… привет, копирование.


Продолжение следует…
Теги:
Хабы:
+23
Комментарии23

Публикации

Истории

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн