Магия WebPush в Mozilla Firefox. Взгляд изнутри
Безусловно одной из самых популярных технологий доставки оповещений на устройства пользователей являются Push уведомления. Технология такова, что для её работы необходим постоянный доступ к интернету, а именно доступ к серверам, на которых регистрируются устройства пользователя для получения уведомлений. В данной статье мы рассмотрим весь спектр механизмов технологии WebPush уведомлений, спрятанных за словами WebSocket, ServiceWorker, vapid, register, broadcast, message encryption и т.д. Основной причиной побудившей меня к реверсу и изучению механизма, являлась необходимость доставки уведомлений мониторинга на рабочие места техподдержки, находящиеся в закрытом сегменте сети без доступа в интернет. И да, это возможно! Подробности под катом.
Disclaimer
В статье рассматривается режим доставки уведомлений пользователям в рамках использования браузера Mozilla Firefox. Это связано с тем, что на данный момент это единственный продукт позволяющий менять настройки push серверов используемых по умолчанию. Настройки браузеров Google Chrome, Chromium и производных в целях безопасности жёстко «зашиты» производителем в коде продукта.
Статья делится на две части
- Теоретическая информация
- Практические заметки для реализации механизма WebPush уведомлений
Используемые технологии и термины
WebSocket
Транспортным ядром системы Push уведомлений является протокол WebSocket, позволяющий в рамках стандартного HTTP/HTTPS подключения к Web серверу установить постоянный двусторонний канал связи между клиентом и сервером. В рамках установленного канала связи могут использоваться любые, в том числе бинарные, протоколы клиент-серверного взаимодействия заложенные разработчиками сервиса.
ServiceWorker
ServiceWorker это внешний автономный механизм обработки и реакции на события, интегрирующийся в браузер путём загрузки логики обработки событий в событийную машину браузера. Перечень событий жёстко зафиксирован в коде браузера и не подлежит быстрому изменению. ServiceWorker по своей сути является частью исполняемого кода, выполняющегося вне контекста пользовательской сессии. Это важное условие, которое обеспечивает безопасность пользовательских данных. Прямое взаимодействие пользователя с ServiceWorker практически невозможно.
VAPID
Механизм формирования авторизационных заголовков для идентификации промежуточных серверов, инициирующих отравку сообщений пользователю.
спецификация VAPID
WebPush
Механизм доставки сообщений до получателя.
Набор документов и спецификаций по WebPush
Workflow
Документации по WebPush довольно много (см. спойлер), но она существует только в парадигме
Client <-> Push Service <-> Application
Модель взаимодействия предполагает следующую схему.
Таким образом создаётся впечатление, что для работы механизма как минимум нужны специализированные сервисы регистрации и приёма/доставки уведомлений, а для отправки сообщений обязателен VAPID, спецзаголовки и ключи. Документация не описывает некоторых внутренних механизмов взаимодействия Push сервера и клиента, которые реально влияют на работу.
Попробуем рассмотреть все процессы подробно.
Фаза обработки сообщения
Меня долгое время интересовало, какие типы сообщений можно отправить браузеру и на что он среагирует.
Вопрос отпал когда я включил отладочный режим для push сообщений в браузере и полез в исходный код Firefox.
try {
reply = JSON.parse(message);
} catch (e) {
console.warn("wsOnMessageAvailable: Invalid JSON", message, e);
return;
}
// If we receive a message, we know the connection succeeded. Reset the
// connection attempt and ping interval counters.
this._retryFailCount = 0;
let doNotHandle = false;
if (
message === "{}" ||
reply.messageType === undefined ||
reply.messageType === "ping" ||
typeof reply.messageType != "string"
) {
console.debug("wsOnMessageAvailable: Pong received");
doNotHandle = true;
}
// Reset the ping timer. Note: This path is executed at every step of the
// handshake, so this timer does not need to be set explicitly at startup.
this._startPingTimer();
// If it is a ping, do not handle the message.
if (doNotHandle) {
return;
}
// A whitelist of protocol handlers. Add to these if new messages are added
// in the protocol.
let handlers = [
"Hello",
"Register",
"Unregister",
"Notification",
"Broadcast",
];
// Build up the handler name to call from messageType.
// e.g. messageType == "register" -> _handleRegisterReply.
let handlerName =
reply.messageType[0].toUpperCase() +
reply.messageType.slice(1).toLowerCase();
if (!handlers.includes(handlerName)) {
console.warn(
"wsOnMessageAvailable: No whitelisted handler",
handlerName,
"for message",
reply.messageType
);
return;
}
let handler = "_handle" + handlerName + "Reply";
Ни одно сообщение отправленное через websocket в сторону браузера не будет обработано, если оно не является системным сообщением проверки доступности конечной стороны »{}» или ответом на запрос от Push сервера. Это означает, что Push сервер не имеет никакого способа воздействия на работу клиентской стороны, кроме проверки её доступности. Аналогично, кроме 5 типов ответных сообщений, ничего обработано не будет.
Фаза инициализации
При запуске браузера Firefox, его внутренний механизм автоматически инициирует соединение с WebSocket (WS) сервером находящимся в системной настройке dom.push.serverURL с сообщением следующего формата.
{
"messageType": "hello",
"broadcasts":
{
"remote-settings/monitor_changes": "v923"
},
"use_webpush": True
}
При первичной инициализации соединения (первый запуск браузера после установки/запуск нового профиля), поле «uaid» отсутствует, что является сигналом Push серверу о необходимости регистрации нового идентификатора. Как мы видим в разделе «broadcasts» присутствует некая пара «remote-settings/monitor_changes»: «v923». Данная пара используется как буфер для хранения информации, отправляемой в сторону сервера при установлении соединения. В продукте Mozilla autopush, промышленной версии webpush сервера используемого на стороне серверов Mozilla, данная переменная используется как идентификатор последнего полученного пользователем сообщения из глобальной очереди сервера. Об изменении данного идентификатора мы поговорим позже. Итак, после принятия сообщения от клиента, сервер отвечает сообщением следующего вида
{
"messageType": "hello",
"status": 200,
"uaid": "b4ab795089784bbb978e6c894fe753c0",
"use_webpush": True
}
Поле uaid заполняется либо присланным со стороны клиента значением, либо новым случайным значением, если uaid был неопределён.
На этом фаза первичной инициализации заканчивается и в принципе между клиентом и сервером ничего больше не происходит до момента регистрации, либо разрыва соедиения.
Фаза регистрации
Под фазой регистрации подразумевается процесс, который предполагает готовность приёма событийной машиной браузера специально сформированных сообщений содержащих данные, транслируемые пользователю.
Фаза регистрации состоит из нескольких шагов:
- Проверка разрешения пользователя на получение информации
- Регистрация ServiceWorker
- Получение параметров подписки
- Формирование ключей шифрования для обслуживания подписки
Проверка разрешений пользователя на получение информации
На данном этапе браузер перед установкой ServiceWorker, запрашивает пользователя и системные настройки: «готов ли пользователь получать сообщения о подписке?»
В случае одного из отказов, установка ServiceWorker прерывается
Регистрация ServiceWorker
Как мы ранее оговаривали, ServiceWorker это автономная страница с обработчиками событий в отдельном пространстве браузера недоступном пользователю.
На работу с этим компонентом накладываются довольно серъёзные ограничения:
- Загрузка компонента ServiceWorker должна производиться через защищённое соединение (HTTPS), либо в целях отладки с localhost. Возможно включение флагов на «небезопасное» использование внешних ресурсов, но это не рекомендуется
- соединение WebSocket должно устанавливаться по защищённому соединению (WSS), либо в целях отладки по обычному WS соединению с localhost
- если в локальной сети имя сервера (ресурса) с которого происходит регистрация ServiceWorker, отличается от полного fqdn ресурса на котором находится ServiceWorker, то будет вызвано исключение о небезопасном вызове
Жизненный цикл ServiceWorker от Google
Жизненный цикл ServiceWorker от Mozilla
Процесс подписки
Процесс подписки подразумевает собой запуск механизма формирования ключей шифрования для создания сообщений и последующего расшифровки данных со стороны Push сервера.
Он состоит из следующих этапов:
- получение публичного ключа шифрования с Web сервера, являющегося промежуточным звеном между Push сервером и приложением осуществляющем отправку сообщений
- формирование браузером ключей шифрования для защиты сообщений
- вызов механизма подписки через канал WebSocket и получение точки для отправки сообщений
Получение публичного ключа
Для того, чтобы приложение могло отправить через Push сервер сообщение клиенту, Push сервер должен удостовериться в том, что отправляющий сообщение промежуточный сервер является доверенным для получателя.
Для получения публичного ключа необходимо обратиться к ресурсу обеспечивающему подготовку сообщения для отправки получателю. Первичное обращение к серверу подразумевает собой выдачу публичного ключа для VAPID идентификации
Запуск процесса генерации ключей шифрования
После получения публичного VAPID ключа вызывается процесс подписки ServiceWorker. Запущенный процесс подписки, используя в качестве идентификатора публичный VAPID ключ, формирует сессионный набор ключей шифрования (приватный ключ, публичный ключ, авторизационный ключ). Сессионный набор публичных ключей является экспортируемым и после окончания подписки может быть получен из пользовательской сессии.
Получение точки для отправки сообщений
После формирования ключей шифрования вызывается процесс внутри браузера называемый register. В сторону Push сервера через WebSocket браузер отправляет запрос вида
{
"channelID": "f9cb8f1c-05e0-403f-a09b-dd7864a03eb7",
"messageType": "register",
"key": "BO_C-Ou.......zKu2U4HZ9XeElUIdRfc6EBbRudAjq4="
}
Это запрос на регистрацию соединения. В качестве параметров регистрации передаётся уникальный номер канала, формируемый каждый раз заново при старте процесса регистрации, а также публичный VAPID ключ сервера, через который будет производиться предварительная обработка и шифрование сообщений.
В ответ на запрос регистрации браузер ожидает сообщение вида
{
"messageType": "register",
"channelID": "f9cb8f1c-05e0-403f-a09b-dd7864a03eb7",
"status": 200,
"pushEndpoint": "https://webpush.example.net/wpush/f9cb8f1c-05e0-403f-a09b-dd7864a03eb7/",
"scope": "https://webpush.example.net/"
}
В данном сообщении содержится адрес конечной точки сформированный Push (WebSocket) сервером, на которую необходимо отправить зашифрованное сообщение для получения его пользователем. Для передачи сообщения получателю, между WEB сервером, принимающим внешние запросы и WS сервером, отправляющим оповещения, должна быть организована логическая связь.
Итого по окончании процесса регистрации и подписки мы имеем следующий набор данных:
Браузер:
- приватный ключ шифрования сообщений
- публичный ключ шифрования сообщений
- ключ авторизации (DH)
- конечная точка для доставки сообщений получателю
- номер канала зарегистрированный на WebSocket сервере
- идентификатор клиента внутри WS соединения
- публичный ключ WebPush сервера
WebPush сервер:
- публичный ключ WebPush сервера
- приватный ключ WebPush сервера
Push (WebSocket) сервер:
- публичный ключ WebPush сервера
- адрес конечной точки клиента
- номер канала клиента привязанный к конечной точке
- идентификатор клиента внутри WS соединения
Из всего набора данных самым странным выглядит WebPush сервер. Я долго не мог понять каким образом происходит весь процесс доставки сообщения до пользователя, но после реверса всех механизмов, а также дебага autopush получилась следующая схема:
- некто хочет отправить сообщение в браузер пользователя
- для защиты сообщения необходимо извлечь из браузера настройки текущей подписки к Push серверу (конечную точку для отправки сообщения, публичный ключ шифрования сообщения, ключ авторизации)
- полученные настройки передаются на промежуточный WebPush сервер вместе с текстом сообщения
- промежуточный WebPush сервер формирует авторизационный JWT токен, содержащий время создания сообщения, адрес администратора WebPush сервера, время действия сообщения и подписывает его при помощи своего приватного ключа
- промежуточный WebPush сервер производит шифрование сообщения при помощи публичного ключа и ключа авторизации из браузера
- промежуточный WebPush сервер вызывает конечную точку полученную из браузера, передавая в неё связку JWT токен+публичный ключ для их проверки в заголовке Authorization, а также бинарный массив зашифрованного сообщения в теле запроса
- Push сервер по вызываемой конечной точке производит привязку запроса к каналу получателя
- Push сервер проверяет валидность JWT токена
- Push сервер конвертирует бинарный массив принятых данных в base64, формирует сообщение типа «notification» с каналом получателя, ставит сообщение в очередь, после чего механизм контроля очереди отправляет сообщение по WebSocket каналу в сторону клиента
Здесь мы прервём процесс для описания формата сообщения типа «notification».
Дело в том, что формат сообщения типа «notification» имеет два варианта. От того, что получил браузер и передал в ServiceWorker зависит логика работы по получению и отображению сообщения. Первый вариант, это «пустое» сообщение:
{
"messageType": "notification",
"channelID": "f7dfeed8-f868-47ca-a066-fbe629879fbf",
"version": "bf82eea1-69fd-4be0-b943-da96ff0041fb"
}
«Пустое» сообщение как бы говорит браузеру «Эй, тебя тут ждут данные, приходи за ними». Браузер по логике работы должен сделать GET запрос на URL конечной точки и получить первую запись из очереди для отображения её пользователю. Схема конечно хорошая, только совсем небезопасная. В большинстве случаев она не применяется.
Вторым вариантом является передача данных совместно с сообщением.
{
"messageType": "notification",
"channelID": "f7dfeed8-f868-47ca-a066-fbe629879fbf",
"version": "bf82eea1-69fd-4be0-b943-da96ff0041fb",
"data": "I_j8p....eMlYK6jxE2-pHv-TRhqQ",
"headers":
{
"encoding": "aes128gcm"
}
}
Браузер реагирует на поле headers в структуре сообщения типа «notification». При наличии этого поля автоматически включается механизм обработки зашифрованных данных из поля «data». На основании номера канала, событийная машина браузера выбирает набор ключей шифрования и пытается расшифровать полученные данные. После расшифровки расшифрованные данные передаются в обработчик «push» сообщений ServiceWorker. Как вы успели заметить, сообщение типа «notification» имеет поле «version», которое представляет собой уникальный номер сообщения. Уникальный номер сообщения используется в системе доставки и отображения сообщений для дедупликации данных.
Она работает следующим образом:
- любое принятое сообщение имеющее поле «version» заносится во внутренний реестр исключений
- корректно принятые и обработанные сообщения остаются в реестре исключений
- некорректно принятые и не обработанные сообщения из рееста исключений удаляются
Информация о причинах такого поведения будет ниже.
Продолжим разбор процесса.
- Если сообщение принято и расшифровано, от браузера в сторону Push сервера формируется новое сообщение с типом «ack», включающее в себя номер канала и номер обработанного сообщения. Это является сигналом удаления сообщения из очереди сообщений для данного канала
- Если сообщение по какой-то причине не может быть обработано, от браузера в сторону Push сервера формируется новое сообщение с типом «noack», включающее в себя номер канала и номер отвергнутого сообщения. Это является сигналом постановки сообщения на повторную доставку через 60 секунд
Вернёмся к сообщениям с типом «broadcast». Продукт «autopush» от Mozilla использует их в качестве хранилища на стороне клиента, для определения последнего отправленного клиенту сообщения. Дело в том, что отправка сообщения типа «broadcast» со сменой значения ключа «remote-settings/monitor_changes», приводит к срабатыванию механизма, сохраняющего полученное значение в хранилище браузера. При потере соединения или каком-то программном сбое, сохранённое значение будет автоматически передано на сторону Push сервера в момент инициализации соединения и будет являться начальной точкой для последующей переотправки пропущенных сообщений из очереди.
Описывать сообщения типа «unregister» смысла не имеет, т.к. оно ни на что, кроме удаления сессии не влияет.
К чему же было приведено подробное описание всех процессов происходящих при Push оповещениях?
Смысл в том, что на основании этих данных можно довольно быстро построить свой Push сервер с необходимым функционалом. Продукт «autopush» от Mozilla является продуктом промышленного масштаба, который рассчитан на многомилионные подключения клиентов. В его составе присутствует TornadoDB, PyPy, CPython. К сожалению движок написан на Python 2.7, который массово выводится из эксплуатации.
Нам же нужен небольшой сервер с простым, желательно асинхронным кодом. А именно, без промежуточного WebPush сервера, VAPID, лишних межсерверных проверок и прочего. Сервер должен уметь привязывать клиентские подключения Push сервера к именам пользователей, а также иметь возможность организации эндпоинтов и webhook’ов для отправки сообщений этим пользователям.
Пишем свой сервер
У нас есть следующие данные:
- Пользователь с браузером Mozilla Firefox;
- Точка регистрации пользователя на сервере уведомлений для получения этих самых уведомлений;
- WebSocket сервер, обслуживающий подключения движка уведомлений, встроенного в браузер;
- Web сервер, формирующий интерфейс для пользователя и обслуживающий точки для отправки уведомлений;
Шаг 1
Первым делом мы должны подготовить WebSocket сервер, обслуживающий описанную ранее логику работы и подключения к нему клиентов.
В качестве фреймворка для реализации логики сервера используется AsyncIO Python.
Изначально стоит сразу разделить понятие «регистрация» для WebSocket движка браузера и понятие «регистрация» на сервере уведомлений. Разница заключается в том, что «регистрация» WebSocket движка браузера происходит автоматически без участия пользователя, в то время как разрешение на «регистрацию» на сервере уведомлений это осознанное действие со стороны пользователя.
Первичной задачей WebSocket сервера является принятие входящего соединения и его контроль на протяжении всего времени подключения браузера к серверу. Поэтому мы должны принять внешнее соединение, сделать его привязку к каналу и сохранить для дальнейшей работы.
После принятия соединения сервером мы подключаем на него обработчик событий, внутри которого будет содержаться вся необходимая для работы информация и функционал отправки сообщений.
Для удобства работы мы используем два META справочника, один для списка соединений, второй для детальной информации о соединении.
# внешнее имя сервера
SERVERNAME='webpush.example.net'
# вебсокеты
WS = set()
# каналы
CHANNELS = dict()
async def register(websocket):
try:
WS.add(websocket)
websocket.handler = PushConnectionHandler(websocket)
except Exception as ex:
logger.error('Register exception: %s' % ex)
async def unregister(websocket):
try:
CHANNELS.remove(websocket.handler.channel_id)
WS.remove(websocket)
logger.debug('UnregisterWebsocket[websocket]: %s'%websocket)
except Exception as ex:
logger.error('Unregister exception: %s' % ex)
async def pushserver(websocket, path):
await register(websocket)
try:
await websocket.send(json.dumps({}))
async for message in websocket:
data = json.loads(message)
logger.info('Incoming message[data]: %s => %s '%(message, data))
if message == '{}':
await websocket.send(json.dumps({}))
elif 'messageType' in data:
logger.info('Processing WebSocket Data')
# Подключение к вебсокету из браузера
if data['messageType'] == 'hello':
# Если это первичное подключение, то нужно задать идентификатор подключения и вернуть его браузеру
if 'uaid' not in data:
data['uaid'] = '%s' % uuid.uuid4()
# Принудительно включить webpush
if 'use_webpush' not in data:
data['use_webpush'] = True
helloreturn = {
"messageType": "hello",
"status": 200,
"uaid": data['uaid'],
"use_webpush": data['use_webpush']
}
websocket.handler.uaid = data['uaid']
if 'broadcasts' in data:
websocket.handler.register_broadcasts(data['broadcasts'])
logger.debug('Hello websocket: %s' % vars(websocket.handler))
CHANNELS.update({ data['uaid'] : websocket.handler })
await websocket.send(json.dumps(helloreturn))
elif data['messageType'] == 'register':
# Регистрация serviceWorker
logger.debug('Register[data]: %s'%data)
registerreturn = {
"messageType": "register",
"channelID": data['channelID'],
"status": 200,
"pushEndpoint": "https://%s/wpush/%s/" % (SERVERNAME,data['channelID']),
"scope": "https://%s/" % SERVERNAME
}
websocket.handler.channel_id = data['channelID']
if 'key' in data:
websocket.handler.server_public_key = data['key']
logger.debug('Register[registerreturn]: %s'%registerreturn)
CHANNELS.update({ data['channelID'] : websocket.handler })
await websocket.send(json.dumps(registerreturn))
elif data['messageType'] == 'unregister':
unregisterreturn = {
"messageType": "unregister",
"channelID": data['channelID'],
"status": 200
}
if data['channelID'] in CHANNELS:
del CHANNELS[data['channelID']]
logger.debug('Unregister[unregisterreturn]: %s'%unregisterreturn)
logger.debug('Unregister[CHANNELS]: %s'%CHANNELS)
await websocket.send(json.dumps(unregisterreturn))
elif data['messageType'] == 'ack':
logger.debug('Ack: %s' % data)
for update in data['updates']:
if CHANNELS[update['channelID']].mqueue.count(update['version']) > 0:
CHANNELS[update['channelID']].mqueue.remove(update['version'])
logger.debug('Mqueue for channel %s is %s' % (websocket.handler.channel_id, websocket.handler.mqueue))
await websocket.send('{}')
elif data['messageType'] == 'nack':
await websocket.send('{}')
else:
logger.error("unsupported event: {}", data)
finally:
await unregister(websocket)
Как вы видите, никакой большой магии в WebSocket сервисе не заложено. Обрабатывается только список основных команд внутри WS сессии согласно спецификации.
Шаг 2
Следующим шагом нужно обеспечить регистрацию клиентской сессии на сервере оповещений.
Для этого необходимо задействовать механизм регистрации и установки ServiceWorker. В сети довольно много примеров, поэтому я взял готовый пример из сети и изменил его, добавив расширение логики.
'use strict';
let isSubscribed = false;
let swRegistration = null;
var wait = ms => new Promise((r, j)=>setTimeout(r, ms));
function urlB64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function subscribeUser() {
const applicationServerPublicKey = localStorage.getItem('applicationServerPublicKey');
const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey);
swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
})
.then(function(subscription) {
console.log('User is subscribed.', JSON.stringify(subscription));
localStorage.setItem('sub_token',JSON.stringify(subscription));
isSubscribed = true;
fetch(subscription.endpoint, {
method: 'POST',
cache: 'no-cache',
body: JSON.stringify(subscription)
})
.then(function(response) {
console.log('Push keys Update Response: ' + JSON.stringify(response));
})
})
.catch(function(err) {
console.log('Failed to subscribe the user: ', err);
});
}
function unsubscribeUser() {
swRegistration.pushManager.getSubscription()
.then(function(subscription) {
if (subscription) {
return subscription.unsubscribe();
}
})
.catch(function(error) {
console.log('Error unsubscribing', error);
})
.then(function() {
console.log('User is unsubscribed.');
isSubscribed = false;
});
}
function initializeUI() {
// Set the initial subscription value
swRegistration.pushManager.getSubscription()
.then(function(subscription) {
isSubscribed = !(subscription === null);
if (isSubscribed) {
console.log('User IS subscribed. Unsubscribing.');
subscription.unsubscribe();
} else {
console.log('User is NOT subscribed. Subscribing.');
subscribeUser();
}
});
(async () => {
await wait(2000);
console.warn('Wait for operation is ok');
swRegistration.pushManager.getSubscription()
.then(function(subscription) {
isSubscribed = !(subscription === null);
if (!isSubscribed) {
console.log('ReSubscribe user');
subscribeUser();
}
})
})()
}
console.log(navigator);
console.log(window);
if ('serviceWorker' in navigator && 'PushManager' in window) {
console.log('Service Worker and Push is supported');
navigator.serviceWorker.register("/sw.js")
.then(function(swReg) {
console.log('Service Worker is registered', swReg);
swRegistration = swReg;
initializeUI();
})
.catch(function(error) {
console.error('Service Worker Error', error);
});
} else {
console.warn('Push messaging application ServerPublicKey is not supported');
}
$(document).ready(function(){
$.ajax({
type:"GET",
url:'/subscription/',
success:function(response){
console.log("response",response);
localStorage.setItem('applicationServerPublicKey',response.public_key);
}
})
});
Основная точка на которую стоит обратить внимание это автоматическая переподписка пользователя при посещении страницы. Работа сервера рассчитана на отправку уведомления по имени пользователя или любому другому идентификатору пользователя, поэтому механизм переподписки всегда будет формировать новую подписку для идентификатора пользователя. Это избавляет нас от проблем с «потерянными» подписками на сервере.
'use strict';
/* eslint-disable max-len */
/* eslint-enable max-len */
function urlB64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function getEndpoint() {
return self.registration.pushManager.getSubscription()
.then(function(subscription) {
if (subscription) {
return subscription.endpoint;
}
throw new Error('User not subscribed');
});
}
self.popNotification = function(title, body, tag, icon, url) {
console.debug('Popup data:', tag, body, title, icon, url);
self.registration.showNotification(title, {
body: body,
tag: tag,
icon: icon
});
self.onnotificationclick = function(event){
console.debug('On notification click: ', event.notification.tag);
event.notification.close();
event.waitUntil(
clients.openWindow(url)
);
};
}
var wait = ms => new Promise((r, j)=>setTimeout(r, ms));
self.addEventListener('push', function(event) {
console.log('[Push]', event);
if (event.data) {
var data = event.data.json();
var evtag = data.tag || 'notag';
self.popNotification(data.title || 'Default title', data.body || 'Body is not present', evtag, data.icon || '/static/images/default.svg', data.url || '/getevent?tag='+evtag);
}
else {
event.waitUntil(
getEndpoint().then(function(endpoint) {
return fetch(endpoint);
}).then(function(response) {
return response.json();
}).then(function(payload) {
console.debug('Payload',JSON.stringify(payload), payload.length);
var evtag = payload.tag || 'notag';
self.popNotification(payload.title || 'Default title', payload.body || 'Body is not present', payload.tag || 'notag', payload.icon || '/static/images/default.svg', payload.url || '/getevent?tag='+evtag);
})
);
}
});
self.addEventListener('pushsubscriptionchange', function(event) {
console.log('[Service Worker]: \'pushsubscriptionchange\' event fired.');
const applicationServerPublicKey = localStorage.getItem('applicationServerPublicKey');
const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey);
event.waitUntil(
self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
})
.then(function(newSubscription) {
// TODO: Send to application server
console.log('[Service Worker] New subscription: ', newSubscription);
})
);
});
Согласно представленного кода, Javascript файл main.js инициирует при своём запуске получение публичного VAPID ключа и принудительно вызывает подписку браузера на оповещения.
Для простоты отладки WebSocket сервер во время регистрации подписки отдаёт URL вида: https://webpush.example.net/wpush/ChannelGuid.
Откуда же берётся имя пользователя в сервере уведомлений. Вся суть в том, что инициирование подписки /subscription/ происходит полуавтоматически. Соответственно в зависимости от того, что вы хотите увидеть в качестве идентификатора пользователя, вы можете передать после оформления подписки в момент передачи ключей.
Это происходит путём вызова метода POST по адресу WebPush endpoint присланного сервером из модуля ServiceWorker.
function subscribeUser() {
const applicationServerPublicKey = localStorage.getItem('applicationServerPublicKey');
const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey);
swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
})
.then(function(subscription) {
console.log('User is subscribed.', JSON.stringify(subscription));
localStorage.setItem('sub_token',JSON.stringify(subscription));
isSubscribed = true;
fetch(subscription.endpoint, {
method: 'POST',
cache: 'no-cache',
body: JSON.stringify(subscription)
})
.then(function(response) {
console.log('Push keys Update Response: ' + JSON.stringify(response));
})
})
.catch(function(err) {
console.log('Failed to subscribe the user: ', err);
});
}
Как было написано ранее, в сервере используется обработчик точек подключения. Это отдельная часть кода в скрипте сервера, но обрабатывающая вместо WebSocket, клиентский WEB трафик от браузера.
В качестве обрабатываемого заголовка, содержащего идентификатор пользователя, в базовом варианте сервиса использовался basiclogin полученный при авторизации пользователя в LDAP.
location ~ /subscription|/pushdata|/getdata|/wpush|/notify {
proxy_pass http://localhost:8090;
proxy_set_header LDAP-AuthUser $remote_user;
proxy_set_header 'X-Remote-Addr' $remote_addr;
add_header "Access-Control-Allow-Origin" "*";
add_header Last-Modified $date_gmt;
proxy_hide_header "Authorization";
add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
if_modified_since off;
expires off;
etag off;
}
Для упрощения демонстрации я добавил проверку в заголовках внешнего адреса пользователя. Также может использоваться токен авторизации, либо любой другой метод привязки пользователя.
USERIDHEADERNAME='X-Remote-Addr'
async def update_channel_keys(request, data):
channel = request.path.replace('wpush','').replace('/','')
logger.debug('update channel keys data: %s'%data)
logger.debug('Update Channel keys Headers: %s' % request.headers)
if USERIDHEADERNAME not in set(request.headers):
return False
basiclogin = request.headers[USERIDHEADERNAME]
logger.debug('Login %s' % basiclogin)
if basiclogin not in LOGINS_IN_CHANNELS:
LOGINS_IN_CHANNELS.update({ '%s'%basiclogin : {} })
LOGINS_IN_CHANNELS['%s'%basiclogin].update({'%s' % channel : {} })
logger.debug('LOGINS_IN_CHANNELS: %s' % LOGINS_IN_CHANNELS)
try:
jdata = json.loads(data)
if 'endpoint' in jdata and 'keys' in jdata:
logger.debug('Saving Keys for Channel: %s => %s' % (channel, jdata))
CHANNELS[channel].register_keys(jdata['keys'])
logger.debug('Registered channel keys %s:' % vars(CHANNELS[channel]))
return True
except Exception as ex:
logger.error('Exception %s'%ex)
return False
Данная функция сохраняет на сервере для текущего канала оповещений пользователя блок ключей и имя пользователя, необходимые для передачи зашифрованного push сообщения в сторону браузера. У пользователя может быть несколько сессий и для каждой из них существует свой набор ключей.
Шаг 3
Сессия зарегистрирована, ключи на сервер переданы, пора отправлять и получать сообщения.
Как я описывал в самом начале статьи, у сервиса оповещений существует два способа доставки сообщений:
- пустое push уведомление, когда браузер «приходит» в очередь сообщений сам
- push уведомление содержащее зашифрованные данные.
При корректном формировании сообщения необходимо передать поле tag содержащее уникальный идентификатор сообщения. Если сервер имеет сессионные ключи для клиента в сторону которого передаётся уведомление, то он может зашифровать это уведомление. Если нет, то сервер отправит пустое уведомление и клиент сам придёт за этим уведомлением по полю tag. Логику получения браузером сообщения реализует следущий блок кода в ServiceWorker:
self.addEventListener('push', function(event) {
console.log('[Push]', event);
if (event.data) {
var data = event.data.json();
var evtag = data.tag || 'notag';
self.popNotification(data.title || 'Default title', data.body || 'Body is not present', evtag, data.icon || '/static/images/default.svg', data.url || '/getevent?tag='+evtag);
}
else {
event.waitUntil(
getEndpoint().then(function(endpoint) {
return fetch(endpoint);
}).then(function(response) {
return response.json();
}).then(function(payload) {
console.debug('Payload',JSON.stringify(payload), payload.length);
var evtag = payload.tag || 'notag';
self.popNotification(payload.title || 'Default title', payload.body || 'Body is not present', payload.tag || 'notag', payload.icon || '/static/images/default.svg', payload.url || '/getevent?tag='+evtag);
})
);
}
});
В случае отсутствия ключей шифрования для уведомления, необходимо организовать очередь сообщений, через которую ServiceWorker сможет получить все ожидающие его сообщения. Так как мы строим простую реализацию сервера, всё что нам нужно это сохранить сообщения до определённого момента, а потом их удалить. Т.к. сообщения мониторинга ждущие получения больше 10 минут, в большинстве случаев уже не актуальны. При этом можно не хранить в очереди «пустые» сообщения для индикации наличия уведомлений на сервере.
Блок шифрования сообщений передаваемых был взят из кода сервера «autopush», дабы не нарушать совместимости.
def encrypt_message(self, data, content_encoding="aes128gcm"):
"""Encrypt the data.
:param data: A serialized block of byte data (String, JSON, bit array,
etc.) Make sure that whatever you send, your client knows how
to understand it.
:type data: str
:param content_encoding: The content_encoding type to use to encrypt
the data. Defaults to RFC8188 "aes128gcm". The previous draft-01 is
"aesgcm", however this format is now deprecated.
:type content_encoding: enum("aesgcm", "aes128gcm")
"""
# Salt is a random 16 byte array.
if not data:
logger.error("PushEncryptMessage: No data found...")
return
if not self.auth_key or not self.receiver_key:
raise WebPushException("No keys specified in subscription info")
logger.debug("PushEncryptMessage: Encoding data...")
salt = None
if content_encoding not in self.valid_encodings:
raise WebPushException("Invalid content encoding specified. "
"Select from " +
json.dumps(self.valid_encodings))
if content_encoding == "aesgcm":
logger.debug("PushEncryptMessage: Generating salt for aesgcm...")
salt = os.urandom(16)
# The server key is an ephemeral ECDH key used only for this
# transaction
server_key = ec.generate_private_key(ec.SECP256R1, default_backend())
crypto_key = server_key.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint
)
if isinstance(data, str):
data = bytes(data.encode('utf8'))
if content_encoding == "aes128gcm":
logger.debug("Encrypting to aes128gcm...")
encrypted = http_ece.encrypt(
data,
salt=salt,
private_key=server_key,
dh=self.receiver_key,
auth_secret=self.auth_key,
version=content_encoding)
reply = CaseInsensitiveDict({
'data': base64.urlsafe_b64encode(encrypted).decode()
})
else:
logger.debug("Encrypting to aesgcm...")
crypto_key = base64.urlsafe_b64encode(crypto_key).strip(b'=')
encrypted = http_ece.encrypt(
data,
salt=salt,
private_key=server_key,
keyid=crypto_key.decode(),
dh=self.receiver_key,
auth_secret=self.auth_key,
version=content_encoding)
reply = CaseInsensitiveDict({
'crypto_key': crypto_key,
'data': base64.urlsafe_b64encode(encrypted).decode()
})
if salt:
reply['salt'] = base64.urlsafe_b64encode(salt).strip(b'=')
reply['headers'] = { 'encoding': content_encoding }
return reply
Самым интересным в данном блоке кода является то, что на каждое сообщение генерируется новая уникальная пара ключей, которая используются при шифровании сообщения. В остальном обычная реализация механизма шифрования данных.
Полноценную логику вновь реализованного сервера описывать в статье можно долго, поэтому по ссылке вы сможете найти готовый webpush сервер, который выполняет всю необходимую работу. Я не стал включать в логику работы webpush сервера блок обработки broadcast запросов и получения данных из очереди, т.к. посчитал это излишним (криптография работает стабильно, поэтому незачем перегружать систему неиспользуемым функционалом). В случае необходимости данный функционал реализуется очень быстро.
WebPush AsyncIO server
Для развёртывания сервера необходимо:
- Установить необходимые Python модули, а также настроить nginx по примеру приложенного конфигурационного файла.
- Поместить содержимое директории web в корень ранее настроенного виртуального сервера
- Перезапустить/перечитать конфиг nginx
- В браузере через about: config поменять параметр dom.push.serverURL на адрес wss://ваш.сервер/ws
- Перед сменой адреса push сервера можно очистить поле dom.push.userAgentID, которое автоматически заполнится если ваш Push сервер работает корректно и принимает соединения.
- Для тестирования оповещений необходимо зайти на страницу https://ваш.сервер/indexpush.html и открыв окно отладки удостовериться в корректной регистрации ServiceWorker
- Нажать кнопку «Check Push Notify»
- Если всё правильно настроено, появится всплывающее сообщение
Как говорилось в начале статьи, система проектировалась для оперативного оповещения службы техподдержки. В зависимости от требуемой логики поведения, в Zabbix