От Action к Any

English version: From Action To Any

Подкапотные нюансы AnyCable для мигрирующих с ActionCable.

Что будет интересного:

  • Как можно посчитать количество пользователей онлайн в ActionCable и почему это сломается в AnyCable
  • Чем отличается current_user в базовом классе Connection при использовании AnyCable от ActionCable.
  • Почему AnyCable гарантировано ломает devise при использовании Rails сессии и как с этим быть.
  • Чем отличается stop_all_streams в AnyCable от своего аналога в ActionCable
  • Как разорвать соединение извне в ActionCable и как это можно сделать AnyCable
  • Где была и куда делась проверка заголовка Origin в AnyCable.

Это небольшое мемо будет интересно для тех кто хочет копнуть на полштыка глубже в теме ActionCable в контексте его альтернативы AnyCable.

Обращаю внимание читателя на то, что все описанные отличия AnyCable от ActionCable актуальны для версии anycable-rails 0.5.2, anycable 0.5.0, и actioncable 5.1.4! Многое из указанного уже вынесено в issue или будет вынесено в ближайшее время и в скором времени будет исправлено или изменено.

Введение

Для тех кто не в курсе: ActionCable это подсистема фреймворка Ruby on Rails для реализации realtime web приложений. AnyCable представляет собой высокопроизводительную альтернативу ActionCable с максимально возможным сохранением экосистемы рельс и ActionCable. Для тех у кого есть вопросы зачем менять родное шило на чужое мыло отвечают три следующих сравнительных картинки .

Используемой памяти:

Benchmark: handle 20 thousand idle clients to the server (no subscriptions, no transmissions). Источник: AnyCable — ActionCable на стероидах

Загрузки ядер:

ActionCable VS AnyCable WebSocket Shootout benchmark, источник: AnyCable — ActionCable на стероидах

Задержки сообщений при росте числа подключений:

Задержка броадкастинга в секундах от количества подключений, источник: AnyCable — ActionCable на стероидах

Так или иначе, вот три причины, каждая из которых уже сама по себе достаточна для того, чтобы задуматься о внедрении AnyCable на место ActionCable.

Дальше встает вопрос цены такого внедрения, что мы приобретем наглядно видно на трех приведенных сравнениях, что мы теряем и какие сложности у нас могут возникнуть на пути внедрения?

Вот хорошая табличка текущей совместимости обощенная Владимиром Дементьевым, автором AnyCable:

Но помимо этой таблички есть еще кое-какие нюансы, которые стоит понимать, при замене ActionCable на AnyCable.

Начнем со стороны того, что никак не цепляет такая перемена: фронтэнда.

Front-end

Со стороны фронта изменения в общем-то отсутствуют, ws-сервера AnyCable полностью совеместимы сейчас с кодом ActionCable на фронте.

Back-end

Вот тут все немножко сложнее. Для начала можем посмотреть на архитектуру, так сказать, с высоты:

Сравнение архитектур AnyCable VS ActionCable. Источник: anycable-rails repo
Более подробно архитектура AnyCable. Источник: ActionCable on steroids

Что мы видим: вебсокет сервер вынесен отдельно, ActionCable заменен на Anycable RPC с вызовом по gRPC.

Minus Rack middleware

Если проявить внимательность, уже на этом этапе можно предугадать первую проблему, с которой придется столкнуться при переводе приложения с ActionCable на AnyCable: несмотря на загрузку Rails приложения, AnyCable это RPC сервис, а ActionCable это Rack based сервер. А это значит, что все Rack-middleware недоступно в AnyCable из коробки! Если вы хотите, что-то использовать вам придется реализовывать это через дополнительные обертки/костыли.

Самый простой и яркий пример, который коснется практически всех, кто будет переводить свое приложение с ActionCable: недоступно Warden middleware, это значит, что если у вас была аутентификация с использованием devise в ActionCable, она сломается. Stackoverflow приводит, например, вот такой способ использования devise совместно с ActionCable:

verified_user = env['warden'].user

В AnyCable это бессмысленно, потому что middle нет и env['warden'] == nil.

Как это можно подлечить? Например так:

gRPC и некоторые некомсомольские методы

Еще одним следствием вынесения поддержания сокетов в отдельный сервер и упрощение Rack приложения до gRPC стало то, что многие функции стали работать в пассивном режиме — всего лишь выставляя значения, которые будут возвращены в ws-server, тогда как в исходном ActionCable они непосредственно осуществляли соответствующие действия.

Например, метод stop_all_streams в исходном варианте непосредственно отписывал стримы от подписок, и после вызова можно считать, что команда отписаться уже ушла на редис, в случае с AnyCable до тех пор пока не произойдет возврата из вызова gRPC в ws-сервер процедура отписки стримов не запустится.

В целом знание этой особенности реализации может быть актуально в двух случаях: если после вызова stop_all_streams далее по коду выкинет эксепшен, то поведение ActionCable будет отличным от AnyCable, и это может помочь быстрее найти проблему на стыке AnyCable и ws-сервера, если таковая возникнет.

Long live Connection!

Продолжаем копаться в классе ActionCable::Connection. В классическом исполнении ActionCable инстансы класса ActionCable::Connection живут все время соединения! Как ни странно, но в документации акцент этот пропущен, основной акцент в документации делается на то что объекты каналов живут долго, но собственно они живут внутри как раз объекта Connection, в коде ActionCable::Channel есть на этот счет отсылка:

Channel instances are long-lived. A channel object will be instantiated when the cable consumer becomes a subscriber, and then lives until the consumer disconnects.
Long-lived channels (and connections) also mean you're responsible for ensuring that the data is fresh. If you hold a reference to a user record, but the name is changed while that reference is held, you may be sending stale data if you don't take precautions to avoid it.

Вот тут мы пришли к следующему отличию AnyCable: в AnyCable нет долгоживущих объектов! Все объекты создаются только на время вызова по gRPC, и сериализуются/десериализуются посредством globalid gem’а.

Таким образом, все ActiveRecord объекты, которые были привязаны в ActionCable::Connection через вызов identified_by, теперь релоадятся перед использованием, т.е. то что раньше было просто current_user, на самом деле теперь всегда current_user.reload.

С одной стороны это удобно: у объекта всегда актуальное на момент вызова состояние. С другой, теперь каждый вызов методов Connection/Channel из JS может приводить к дополнительным обращениям к БД, по числу таких idetified_by объектов, и в ряде случаев, имеет смысл отказаться от использования ActiveRecord объектов заменив их на простые структуры.

Броадкастну, а в ответ тишина…

Продолжаем по прежнему копаться в классе Connection и его отличиях от базового варианта. Теперь нас интересуют InternalChannel и RemoteConnections ( оба находятся в namespace ActionCable::Connection). Каждый создаваемый на бекенде объект коннекшен не только контролирует подписку пользователя на каналы, но и сам дополнительно создает и подписывается на внутренний канал сообщений, который в частности используется для принудительного разрыва соединения извне, например, из основного Rails приложения.

Если мы запустим обычный демо пример action cable, который приводится в описании ActionCable, присоединимся из двух браузеров к паре каналов и после этого посмотрим как дела в Redis.pubsub, мы увидим вот такой вывод:

2.4.1 :020 > redis.pubsub('channels')
 => ["action_cable/Z2lkOi8vYWN0aW9uY2FibGUtZXhhbXBsZXMvVXNlci8x", 
"messages:1:comments",
"messages:2:comments",
"_action_cable_internal", "action_cable/Z2lkOi8vYWN0aW9uY2FibGUtZXhhbXBsZXMvVXNlci8z"]

Где каналы с префиксом messages — это каналы чат комнат, которые и приводятся в коде демо-приложения, а каналы с префиксом action_cable/ — это внутренние каналы соединений, о которых было сказано выше. Если мы запустим AnyCable приложение и попробуем заглянуть в pubsub redis’а то мы увидим там только одну очередь сообщений, по умолчанию она называется __anycable__!

На что влияет такая реализация? Ну во-первых пропускная способность всех ваших кабелей теперь зависит от пропускной способности одной очереди pubsub в редисе. Во-вторых полезные советы как определить кто присоединен к вашим сокетам и сколько их, стали теперь вредными, в смысле, нерабочими.

Ну и возвращаясь к теме заголовка, если мы заглянем в код класса RemoteConnections, чтобы подсмотреть как отключаются связи извне мы увидим:

ActionCable.server
.remote_connections
.where(current_user: User.find(1))
.disconnect

и в методе дисконнект:

server.broadcast internal_channel, type: "disconnect"

Таким образом в ActionCable пройдет сообщение disconnect и соединение закроется, но в AnyCable каналов таких нет, напомню в anycable у нас только одна очередь __anycable__, и этот броадкаст молча уйдет в молоко.

В общем как и говорила нам табличка совместимости:

Disconnect remote clients : —

Как в итоге разорвать соединение извне в AnyCable? Без внесения изменений в ws-сервер, в общем-то, нет гарантированного способа выборочно разорвать соединение из основного rails-приложения, есть только ненадежный: допилить на front-end’е класс канала и отправить на такой доработанный класс специальное сообщение, которое заставит сокет пересоединиться, в чем мы ему можем с удовольствием отказать. Если по каким-то причинам JS проигнорирует наше сообщение, придется ждать или обновления вашего любимого варианта AnyCable ws-сервера, или на худой конец его перезапуска.

Пара слов про конфиг

Про конфиг ActionCable можно почитать в официальном гайде, в двух словах в config/cable.yml лежат настройки pubsub адаптера, остальные можно вписать через config.action_cable.

Про конфигурацию Anycable есть кратко в официальном репозитории Основные отличия:

  • Не используется cable.yml для своих настроек, во-первых потому что pubsub адаптера как такового на уровне gRPC-сервиса нет и в нем нет потребности, потому что на сообщения подписывается непосредственно ws-сервер, а во-вторых потому что для целей броадкастинга используется только redis его можно только настроить, но выбрать вместо него другой движок для pubsub, например postgreSQL, нет возможности*.
  • Для заполнения настроек используется гем anyway_config, так что настройки можно выставить и через окружение, и через config/anycable.yml, и вообще через вызов Anycable.configure.

Также подробнее про связь настроек в config/anycable.yml с параметрами запуска ws-сервера можно прочитать в комментариях к дефолтным настройкам config/anycable.yml добавленным в генератор с версии anycable-rails 0.5.2.

*Речь про текущую версию AnyCable и anycable-rails ( 0.5.2 ), добавление других pubsub адаптеров указано в планах и обсуждается

Cross Site WebSocket Hijacking (CSWSH).

Основной способ защиты от кроссайт подписки на вебсокеты — проверка заголовка Origin, его цепляет браузер в обязательном порядке, и только очень редкая экзотика типа опасных расширений браузера, использование нешифрованного ws соединения и пр. допускают возможность обмана проверки заголовка Origin, но в таких случая сама сессия уже с большой долей вероятности может быть скомпрометирована.

В контексте AnyCable это проявляется на старте ws-server’а, ему указываются заголовки, которые он будет передавать на gRPC сервис:

-headers=cookie,x-api-token,origin

Как видно можно разрешить передавать куки для определения сессии и origin для его проверки. Но в отличе от ActionCable, в Anycable gRPC отсутствует встроенная проверка (allow_request_origin?):

# ActionCable::Connection::Base
# Called by the server
when a new WebSocket connection is established. This configures the callbacks intended for overwriting by the user.
# This method should not be called directly -- instead rely upon on the #
connect (and #disconnect) callbacks.
def process #:nodoc:
logger.info started_request_message
if websocket.possible? && allow_request_origin?
respond_to_successful_request
else
respond_to_invalid_request
end
end

Проверку origin легко можно вернуть в приложение самостоятельно, используя вызов allow_request_origin? и настройки ActionCable:

config.action_cable.allowed_request_origins = ['http://rubyonrails.com', %r{http://ruby.*}]

Или же переложить проверку на плечи nginx.

Заключение

В целом, отличия реализации AnyCable являются не более чем легкими особенностями, а никак не заметными недостатками, которые могут как-то повлиять на решение о внедрении AnyCable.


Мои благодарности Владимиру Дементьеву автору AnyCable за сам гем и замечания к данной статье.


Ссылки

ActionCable:

  1. ActionCable guide
  2. Ответы на вопросы как определить кто присоединен к вашим сокетам и сколько их
  3. Chat app rails 5 + ActionCable + devise
  4. ActionCable examples

Anycable:

  1. AnyCable — ActionCable на стероидах
  2. Anycable.io
  3. Anycable на гитхабе

Безопасность сокетов:

  1. OWASP проверка сокетов на предмет разных угроз в том числе кроссайтового подключения
  2. Большое и подробное мемо на тестирование Вебсокетов на безопасность от горячих финских парней, в том числе по вопросу Crossite ws Hijacking
  3. Еще про кроссайтовые сокеты и как уберечься от Кристиана Шнайдера.
  4. Ответы на security.stackexchange.com: Preventing CSRF against Websockets и Is Origin header useful for websocket protection.
  5. Как подделать ориджин в JS ( ну в общем практически никак, либо куки не прилипнут, либо ориджин будет исходный)
  6. Еще статья про безопасность WS с обсуждениями