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

0 Favorite

[

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

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

Большинство технологий разработки 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 я даже не пытаюсь это делать… страашно. Ладно, может, попробуем, как сады зацветут.)

А ещё есть проблема “https://habr.com/ru/post/543782/#”. По RFC, такое не разрешено:

INVITE sip:123#456@example.com SIP/2.0

From: <sip:123#789@example.com>; tag=from1

но не только лишь все ™, начиная с Cisco, это игнорируют. Поэтому почти сколько существует наш продукт, почти столько и существует параметр получателя url_hash_safe, который говорит рисовать #, а не %23 (а %23 многие из них не понимают). Но некоторые наоборот, не понимают, если ставить “https://habr.com/ru/post/543782/#”. Поэтому нужна регуляция по конкретному ремотному участнику — определять, кому таторы, а кому ляторы. Последний раз я подсказывал техподдержке включить этот параметр прошлой осенью. Наверно, эта проблема будет жить вечно. Ах да, ‘*’ — ещё один частый символ в этих последовательностях — разрешён явно и проблемы не вызывает. Неаккуратненько-с.

В 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, например? Границы строк чётко определены. Кошмариков типа "\rn", которое неверно разбирали 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, с учётом всех тонкостей по содержанию (см. выше про адресные поля).

По непонятной прихоти call-id регистрозависим, а теги — нет. Точнее, «так в книге написано» ™, что они регистронезависимы. Реально же:

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; пробелы до и после ‘:’ в неограниченном количестве; и так далее). А как только мы выходим за пределы средств с прямым изменением в буферах… привет, копирование.

Продолжение следует…



Перейти в источник

Похожие статьи

О классах Program и Startup — инициализация ASP.NET приложения. Часть II: IWebHostBuilder и Startup / Хабр

0 Favorite [ Введение Это – продолжение статьи, первая часть которой была опубликована ранее. В той части был рассмотрен процесс инициализации, общий для любого приложения…

Инвентаризация ИТ-активов штатными средствами Windows с минимальными правами доступа

0 Favorite [ Коллеги, в предыдущей статье мы обсудили принципы эффективной работы с событиями аудита ОС Windows. Однако, для построения целостной системы управления ИБ важно…

Ответы