[О блоге] [наверх] [пред] [2024-04-10 00:47:05+03:00] [4a521b9d638a8d23487ff6c36ac3be97c8c464b3]
Темы: [crypto][go][multimedia]

Моё новое поделие: VoRS

http://www.vors.stargrave.org/
Как-то я бросался словами что надо написать свой собственный VoIP клиент
(+сервер). А то из всего VoIP есть только Mumble, который хоть как-то
ещё можно собрать и который хоть как-то но работает. Хоть как-то -- это
значит что всё равно со сторонними реализациями всё плохо (Mumble на Go):
7dac01b0761a750312eef3765d3131e36fac95aa
6bf8ec6fda4ba9a2ee54819e4a6613ff33d8effe
ecf0bbd8f4f25d6039438e1c6756c518e6979cfb
Отдельная боль в Mumble -- его Murmur сервер, который хоть и не GUI, но
требует Qt.

За три последних дня, не забывая про работу, я осилил написать своё
решение. Когда-то я думал что это вообще на Си стоило бы, но увидел, что
Go-шная wrapper библиотека для libopus существует, в ней почти нет кода
(буквально просто обёртки), замечательно работает. В итоге Си как то сам
собою отпал.

Около экрана кода достаточно чтобы P2P Opus закодированный трафик по UDP
передавать. Я было обрадовался как оказалось всё просто. Но вот если
захочется больше чем два человека, то тогда что делать? Всем
перезапускаться и указывать ещё один дополнительный IP адрес? Геморрой.
Да и я не планировал и яростно против любых NAT-traversal технологий, но
отдельный сервер всё же вполне себе был бы и решением до сих пор
остающихся неподключёнными к Интернету людей (которые за NAT).

И с его появлением всё сразу как-то сразу усложнилось. Но я постарался
сделать так, чтобы всё же проще было некуда. VoRS: Vo(IP) Really Simple.

* каждый клиент подключается по TCP к серверу
* на нём заранее генерируется X.509 сертификат самоподписанный, ed25519
  алгоритм, хэш от SPKI выдаётся клиентам
* инициируется TLS 1.3 с curve25519 DH. Проверяется SPKI хэш сервера
* далее внутри TLS следует текстовый построчный протокол: сервер
  отсылает 128-бит challenge; мы отвечаем BLAKE2s(пароль, challenge) и
  username-ом. Серверу и клиентам заранее указываются пароли подключения.
  Факт успешного расчёта MAC-а над challenge означает что клиент
  аутентифицирован и авторизован к подключению
* если с паролем всё ok, если username не сдублирован, то сервер или
  отвечает "OK <SID>" или сообщением с ошибкой. SID это stream
  identifier -- 8-бит число, по сути просто идентификатор подключённого
  клиента
* далее по этому TLS-у раз в 10сек бегают PING/PONG, с отключением если
  долго от противоположной стороны ничего не было

X.509 -- потому что из коробки в Go есть. X.509+пароль -- точно так же
это устроено и в Mumble. То бишь для Mumble-пользователей привычно.

После успешной авторизации, сервер и клиент вырабатывают симметричный
ключ шифрования UDP трафика, используя встроенную возможность TLS 1.3 в
виде Export Keying Material.

Аудио читается кусками по 20мс -- рекомендованное значение Opus-а.
48kHz, 1 канал, 16-бит S-LE. Натравливается функция кодирования Opus,
получается несколько десятков байт пакет. К нему добавляется 32-бит
заголовок: 8-бит SID и 24-бита счётчик пакетов. 24-бита достаточно для
многодневной беседы без остановки, учитывая что отсылается 50pps.
Счётчик используется для обнаружения переупорядочивания и потерь
пакетов. Используются PLC (Packet Loss Concealment) возможности libopus
для сглаживания потерь.

Bitrate выставлен в 32Kbps. Изначально выставлял 24Kbps, как
рекомендовано. Но... 4-байт заголовок VoRS, 40-байт заголовок IPv6,
16-байт на MAC, 8-байт на UDP... выходит что размер payload-а меньше чем
overhead на передачу! Поэтому пускай будет 32Kbps, чтобы всё же чуть
больше чем overhead быть, и суммарно получить 64Kbps трафика.

Раз в секунду отправляется пакет с 1-им байтом SID-а, чисто для UDP hole
punching-а stateful firewall-а.

Собственно, ключ EKM используется для ChaCha20-Poly1305. В качестве
nonce которого используется счётчик пакетов. Пока используются все
128-бит Poly1305, но я думаю что имеет смысл сократить в два раза.

UDP трафик от клиента отправляется на сервер, который только смотрит на
первый байт SID-а и UDP IP:порт. Клиент шлёт UDP трафик с такого же
номера порта по которому он подключился для TCP. А дальше сервер просто
буквально рассылает копии пакета всем остальным. Да -- это пока самое
неприятное место, ибо микшированием аудиопотоков сервер не занимается и
поэтому объём трафика растёт пропорционально кол-ву участников. Но на
моей практике, людей в Mumble буквально не больше 4-5, и это речь про
64Kbps поток от каждого.

Как же дешифровать то трафик могут другие, ведь у них же свои TLS
соединения со своим state-ом. Сервер по TLS-у просто сообщает текстовой
строкой о факте подключения нового участника: ADD SID USERNAME KEY. Если
кто отключается, то: DEL SID. Безусловно пока есть race между тем как
дойдёт ADD/DEL по TCP до клиентов и параллельно с этим идущим UDP
трафик. Но да и фиг с ним: речь про доли секунды возможно ещё не
дешифрующегося трафика.

Когда сервер научится микшировать аудиопотоки, то от него будет идти
ровно один stream с audio. Можно будет избавиться от SID-а в принципе.
Если дойдут до этого руки, ибо задача вроде бы отнюдь не тривиальна.
Сервер сейчас даже не работает с криптографией UDP пакетов, хотя мог бы
проверять MAC например (ключ же он знает).

Самая жопа это ввод и вывод аудио. Ничего портабельного, кроме говна
типа PulseAudio или очередных его заменителей -- нет. OSS4 это мир BSD.
ALSA это Linux. JACK из коробки не стоит, да и я не знаю адекватно ли с
ним работать. Видел и даже трогал софт с OpenAL -- но на Go как-то оно
всё не то чтобы стабильно работало (может быть это софт был говно, а не
с OpenAL дело).

Пока решил поступить по тупому: SoX-овый rec для того, чтобы из него
просто забирать поток PCM байт. Его же play для воспроизведения. Приятно
то, что он не требует чтобы я поток постоянно выдавал. Если мне не
приходят UDP/Opus пакеты, то в play я байты никакие не подаю и это не
проблема. Для каждого клиента/stream-а я запускаю ещё один "play".
Насколько знаю, возможности микшировать потоки с разных приложений
зависят от драйверов и вообще звуковой подсистемы. Но вроде бы и ALSA и
OSS давно без проблем это всё из коробки умеют уже давно. У меня play
прекрасно запускается в большом количестве и асинхронно к ним подаются
кусочки звуковых данных -- всё тип-топ работает.

Вместо play/rec можно использовать всё что угодно другое. Это просто
команда которая или с stdout или на stdin должна принимать PCM данные.
Хоть ffmpeg засунуть -- должно быть пофиг. И по идее это нигде не должно
создавать проблем. Проверял на какой-то Ubuntu не самой свежей (с
LiveCD), ну и на своих FreeBSD, как со встроенными Intel HDA
звуковухами, так и подключёнными через USB или даже virtual_oss. Если я
доберусь до микширования звука на сервере, то этот же код можно будет
использовать и на клиенте, для того чтобы ровно один "play"/whatever
запускать, если будет в этом смысл.

Ну и оно должно быть удобно для использования. Я люблю бегающие чиселки.
Mumble настолько ничего не показывает, что частенько он говорит что типа
всё ok, мы работаем, вот только звуковое устройство отвалилось, как и
сервер.

Решил сразу сбацать TUI интерфейс. Экран для произвольных логов (ошибки,
события подключения), и для каждого участника по окошечку, где
перечисляются кол-во принятых/переданных пакетов (на сервере, а на
клиенте только принятых от остальных, или переданных от себя), размеры в
байтах (только payload конечно же), потерянные или переупорядоченные
пакеты,

Если последний UDP пакет был принят более секунды назад -- считается что
пользователь молчит. В противном случае показывается "TALK" зелёным
цветом. Как на сервере, так и на клиентах. Нажатием Enter можно
включать/выключать mute локальный -- UDP просто не будет отсылаться.

Не знаю зачем, но всегда хотелось иметь прыгающую полосочку громкости
когда человек говорит. Вычисляю RMS 200мс отрезков и рисую bar. С такой
же периодичностью обновляю экран.

В планах не было делать Voice Activity Detection, но раз я уже умею
вычислять RMS, то что мешает добавить VAD? Я не очень понял про его
значения, не вдавался в подробности, но просто написал утилитку, которая
выводит RMS значение для звука из rec-а. На глаз можно оценить какой
порог надо задать и передать его в клиента -- тихий звук до него будет
считаться тишиной и никакой UDP передаваться не будет. Вроде работает
отлично.

В "бою" я это ещё не проверял -- только в домашних условиях. Но с
задержками проблем не увидел, собралось под старой Ubuntu без проблем,
работает прям отлично как-будто. Запросто где-то фатальные косяки, ибо
делал на скорую руку. Но по идее оно полностью покрывает функционал
Mumble для просто общения (вне игр, где всякие positional audio).

Нет разделения по "комнатам". Конечно можно и добавить, но думаю что
проще поднять ещё один сервер на соседнем порту. У него из аргументов то
пути только bind, путь-к-pem и пароль. Плюс он не требует libopus или
чего-то подобного: обычная статически собранная Go программа.

    [оставить комментарий]