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. Для тех у кого есть вопросы зачем менять родное шило на чужое мыло отвечают три следующих сравнительных картинки .
Используемой памяти:
Загрузки ядер:
Задержки сообщений при росте числа подключений:
Так или иначе, вот три причины, каждая из которых уже сама по себе достаточна для того, чтобы задуматься о внедрении AnyCable на место ActionCable.
Дальше встает вопрос цены такого внедрения, что мы приобретем наглядно видно на трех приведенных сравнениях, что мы теряем и какие сложности у нас могут возникнуть на пути внедрения?
Вот хорошая табличка текущей совместимости обощенная Владимиром Дементьевым, автором AnyCable:
Но помимо этой таблички есть еще кое-какие нюансы, которые стоит понимать, при замене ActionCable на AnyCable.
Начнем со стороны того, что никак не цепляет такая перемена: фронтэнда.
Front-end
Со стороны фронта изменения в общем-то отсутствуют, ws-сервера AnyCable полностью совеместимы сейчас с кодом ActionCable на фронте.
Back-end
Вот тут все немножко сложнее. Для начала можем посмотреть на архитектуру, так сказать, с высоты:
Что мы видим: вебсокет сервер вынесен отдельно, 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:
- ActionCable guide
- Ответы на вопросы как определить кто присоединен к вашим сокетам и сколько их
- Chat app rails 5 + ActionCable + devise
- ActionCable examples
Anycable:
Безопасность сокетов:
- OWASP проверка сокетов на предмет разных угроз в том числе кроссайтового подключения
- Большое и подробное мемо на тестирование Вебсокетов на безопасность от горячих финских парней, в том числе по вопросу Crossite ws Hijacking
- Еще про кроссайтовые сокеты и как уберечься от Кристиана Шнайдера.
- Ответы на security.stackexchange.com: Preventing CSRF against Websockets и Is Origin header useful for websocket protection.
- Как подделать ориджин в JS ( ну в общем практически никак, либо куки не прилипнут, либо ориджин будет исходный)
- Еще статья про безопасность WS с обсуждениями