Исходящий HTTP
Приложение, которое обращается к другому серверу (вебхук, входящий вебхук
Slack, сторонний API), использует Cybertrain::HttpClient. Он не входит в
require "cybertrain"; подключайте его там, где он используется, а также слой
TLS, если какой-либо URL — https:
require "json"
require "cybertrain/http_client"
require "cybertrain/net/tls" # только для https://; компонуется с системной libssl
client = Cybertrain::HttpClient.new(timeout: 5, deadline: 15, max_body: 1 << 20)
begin
res = client.post(ENV["SLACK_WEBHOOK_URL"], JSON.generate({ "text" => "Deployed" }),
content_type: "application/json")
Cybertrain.logger.warn("slack answered #{res.status}") unless res.success?
rescue Cybertrain::HttpClient::Error => e
Cybertrain.logger.warn("slack: #{e.message}") # называет host:port, но никогда путь
end
res = client.get("https://api.example.com/v1/status", headers: { "Authorization" => "Bearer #{token}" })
res.status # 200
res.header("ETag") # имена в любом регистре; в res.headers они в нижнем регистре
res.body # байты, с удалённым chunked-кодированием
Зачем он нужен: Net::HTTP из Spinel выполняет рукопожатие TLS и каждое
чтение после первого байта блокирующими вызовами C, и каждый из них удерживает
рабочий поток ОС — а при SPINEL_WORKERS=1 по умолчанию это весь веб-сервер —
столько, сколько захочет зависший эндпоинт. HttpClient никогда не блокирует
рабочий поток на сети:
- Каждое ожидание ограничено. Подключение (неблокирующий connect;
TCPSocket.newиз Spinel игнорируетconnect_timeout:), каждый шаг рукопожатия TLS, каждое чтение и каждая запись ждут не болееtimeoutсекунд (по умолчанию 5), а весь запрос — не болееdeadlineсекунд (по умолчанию 15). При превышении любого из них —HttpClient::TimeoutError. Чтение идёт через байтовый буфер, наполняемыйread_nonblock, поэтому уже полученные байты никогда не ожидаются. - Разрешение имён блокирует. У
getaddrinfoв среде выполнения нет неблокирующей формы: поток, выполняющий запрос, ждёт DNS (обычно миллисекунды, но неработающий резолвер может занять системный тайм-аут, около 5 с на попытку). Крайний срок учитывает это время, но не может его прервать. - Непубличные адреса назначения отвергаются до того, как что-либо
отправлено: loopback (
127.0.0.0/8,::1), частные (10/8,172.16/12,192.168/16,fc00::/7), link-local (169.254/16, куда входят облачные сервисы метаданных,fe80::/10), неопределённые (0.0.0.0/8,::), multicast, broadcast, CGNAT (100.64/10), диапазоны для документации и бенчмарков, а также их формы IPv4-mapped, NAT64 и 6to4 (Cybertrain::Net::Address.blocked_reason). Имя хоста разрешается один раз, и проверяются и набираются именно те адреса, в которые оно разрешилось (повторно ничего не ищется), поэтому имя, разрешающееся в частный адрес (запись в/etc/hosts, DNS rebinding), отвергается так же, как литерал; сертификат TLS всё равно проверяется по имени. Проверка идёт по каждому адресу: отвергнутый адрес пропускается, и запрос завершается сбоем, только когда отвергнуты все адреса.HttpClient::BlockedAddressErrorсообщает причину. Чтобы обратиться к сервису в собственной сети или к локальному серверу в тесте, задайтеc.http_allow_private = trueвconfig/app.rb(илиCYBERTRAIN_HTTP_ALLOW_PRIVATE=1) либо передайтеallow_private: trueвHttpClient.new; оставляйте это выключенным всякий раз, когда URL может прийти от пользователя. - Перебираются все разрешённые адреса в порядке, который даёт резолвер,
IPv6 и IPv4 одинаково: когда один отказывает, недоступен, сбрасывает
соединение или не отвечает в пределах
timeout, набирается следующий, все — в пределахdeadline, а когда не удаётся ни один, возбуждается ошибка последнего. Именно благодаря этомуlocalhostработает на macOS, где он разрешается сначала в::1, а сервер, слушающий на127.0.0.1, такое соединение отвергает.Cybertrain::Mailerподключается так же. - TLS берётся из пакета openssl:
VERIFY_PEERпо хранилищу доверенных сертификатов операционной системы (SSL_CERT_FILE/SSL_CERT_DIRуказывают на другое; у пакета нетX509::Storeдля собственного), проверка имени хоста, SNI, TLS 1.2 или новее. URL https нужно имя хоста: IP-адрес не совпадает с именем в сертификате. Безrequire "cybertrain/net/tls"запрос https возбуждаетHttpClient::TLSError, который говорит об этом; одинcybertrain/http_clientникогда не компонуется с libssl. - Клиент для уведомлений, а не браузер: HTTP/1.1 с
Connection: close, одно соединение на запрос, без keep-alive, без редиректов (3xx возвращается как есть), без прокси, без cookie, без сжатия. Ответы могут быть сContent-Length, chunked или читаться до закрытия; заголовки ограничены 64 КиБ и 100 строками, тело —max_body(по умолчанию 1 МиБ), сверх которого —HttpClient::TooLargeError. Ответ, который не является HTTP/1.x или обрывается раньше времени, — этоHttpClient::BadResponseError; отвергнутое или сброшенное соединение либо имя, которое не разрешается, —HttpClient::RefusedError. Соединение, которое сбрасывается или закрывается во время рукопожатия TLS (сервер, не говорящий на TLS), — этоHttpClient::TLSError(«TLS handshake failed») на любой платформе, как и рукопожатие, которое отвергает OpenSSL. Все они —HttpClient::Error. - Ошибки никогда не содержат путь или строку запроса, в которых URL
вебхука несёт свой секрет: сообщения называют только хост и порт.
Некорректный URL или заголовок с CR/LF — это
ArgumentError, который тоже его не повторяет.Host,Connection,Content-LengthиTransfer-Encodingпринадлежат клиенту;User-Agentпо умолчанию —cybertrain/<version>.
Запрос выполняется в вызывающем потоке — зелёном потоке, как и обслуживаемый
запрос, — поэтому, пока он ждёт, другие соединения продолжают обслуживаться.
Слой сокетов (cybertrain/net/socket.rb: ограниченный connect, буферизованное
чтение, ограниченная запись) отделён от слоя HTTP, чтобы им могли пользоваться
другие протоколы. Проектное решение: docs/design.md D24.
Потребление потоков WebSocket
Приложение, которое слушает внешний поток (биржевые тики, телеметрию
устройств, поток событий), пока работает сервер, подписывается на него через
Cybertrain::WebSocket.subscribe. Как и HTTP-клиент, он не входит в
require "cybertrain"; подключайте его там, где пишется подписка, а для
wss:// — также слой TLS:
# config/app.rb (или файл, который bin/<app>.rb подключает до Cybertrain::Main.run)
require "json"
require "cybertrain/websocket"
require "cybertrain/net/tls" # только для wss://; компонуется с системной libssl
Cybertrain::WebSocket.subscribe("trades", ENV["FEED_URL"],
headers: { "Authorization" => "Bearer #{ENV["FEED_TOKEN"]}" },
stale_after: 30)
.on_open { |conn| conn.send_text(JSON.generate({ "op" => "subscribe", "channel" => "trades" })) }
.on_message { |conn, text| Cybertrain::DB.with { |c| c.execute("INSERT INTO trades(raw) VALUES (?)", [text]) } }
Подключение cybertrain/websocket регистрирует потоки в
Cybertrain::Background, который Application#serve запускает после
планировщика и останавливает при завершении работы, так что вызывать ничего не
нужно. Каждая подписка получает один поток выполнения. Он подключается,
выполняет on_open (отправляйте там своё сообщение о подписке: оно
выполняется снова после каждого переподключения), а затем передаёт каждое
сообщение в on_message вместе с Connection (send_text, send_binary,
close и last_opcode — :text или :binary). Блоки получают свои аргументы
и не могут использовать self (в Spinel нет instance_eval).
- Когда это выполняется. Потоки запускаются, когда бинарный файл
обслуживает запросы, как и планировщик
(Фоновая работа); команды
migrate,db ...,Main.commandиCybertrain::Test::Clientникогда их не запускают. СCYBERTRAIN_WORKER=0(config.worker = false) они не работают в этом процессе (они просто не запускаются; единственное уведомление — строка планировщика «run elsewhere», аWebSocket.start!, вызванный напрямую, пишет в лог свою): когда запросы обслуживают несколько процессов, отключите это во всех, кроме одного, иначе каждый получит весь поток.subscribeнужно вызвать до запуска сервера; добавлять или удалять поток во время работы нельзя. Имя уникально (ArgumentError), а поток безon_messageсрывает запуск до того, как занят порт (error: WebSocket subscription "trades" has no on_message, код выхода 1). - Параметры.
headers:(дополнительные заголовки рукопожатия),timeout:(5 секунд на каждое ожидание при подключении и на каждую запись),max_message:(1 МиБ; более крупное сообщение закрывает соединение с кодом 1009),ping_interval:(15) иstale_after:(45), а такжеallow_private:(как уHttpClient, ниже). - Ожидание и переподключение. После любого сбоя поток ждёт
min(60, 2 ** (attempt - 1))секунд с разбросом 20 % в обе стороны: 1, 2, 4, ... 60. Соединение, продержавшееся 60 секунд, начинает снова с 1. Каждое ожидание ограничено.WebSocket.stop!возвращается примерно через 2 секунды, что бы ни делали серверы: он ждёт не более 2 секунд все потоки вместе, подключённый поток отправляет close (1000) и ждёт ответа сервера не более 1 секунды, и после возвратаconnected?каждого потока ложно (поток, который всё ещё закрывается, завершается сам). - Тишина. Поток может замолчать, так и не закрыв соединение, поэтому поток
выполнения отправляет пинг после
ping_intervalсекунд без единого фрейма (и затем раз в интервал), а когда заstale_afterсекунд не пришло ни одного фрейма, включая pong, закрывает соединение и переподключается.stale_afterдолжен быть большеping_interval, который должен быть не меньше 1 (ArgumentErrorотsubscribe). - Ошибки в ваших блоках. Исключение в
on_messageпишется в лог, и соединение остаётся. Исключение вon_openпишется в лог, соединение закрывается и повторяется, поскольку от потока, который не подписался, нет пользы. -
Что пишется в лог.
Когда Уровень Строка запуск INFO WebSocket started: trades (wss://feed.example.com:443)подключение INFO WebSocket "trades" connected to feed.example.com:443отключение WARN WebSocket "trades" disconnected: Cybertrain::WebSocket::ClosedError: feed.example.com:443: closed by the server (1001) (retry in 2s)тишина WARN WebSocket "trades" silent for 45s; reconnectingблок возбудил исключение ERROR WebSocket "trades" on_message failed: KeyError: ...(илиon_open)Путь и строка запроса никогда не пишутся в лог и не попадают в сообщения об ошибках: в них часто содержится токен.
- Ошибки. Все они —
Cybertrain::WebSocket::Error, и в сообщении только хост и порт. После рукопожатия потерянное соединение или закрытие сервером — этоClosedError(close_code,close_reason; сброс или конец без close-фрейма — 1006); запись, которая превышает тайм-аут или не удаётся в TLS, — по-прежнемуTimeoutErrorилиTLSError. Во время подключения этоRefusedError,TimeoutError,TLSError,BlockedAddressError(непубличный адрес, по умолчанию отвергаемый ровно так же, как в Исходящий HTTP, с тем жеallow_private:и настройкойhttp_allow_private) илиHandshakeError(не 101, неверныйSec-WebSocket-Acceptили сервер, ответивший с расширением или подпротоколом).ProtocolError(сервер нарушил формат фреймов; клиент отправляет 1002) иTooLargeErrorмогут возникнуть в любой момент. - Состояние.
Cybertrain::WebSocket.feed_status("trades")возвращаетStatus:connected?,last_message_at(секунды эпохи, 0 до первого сообщения) иreconnects. Это не входит вGET /up; используйте это в собственной проверке. Все три читаются одно за другим, а не как единый снимок, иconnected?становится истинным, когда соединение установлено, до того как выполнилсяon_open. - Делайте
on_messageкоротким. Поток выполнения ждёт черезwait_readableилиsleep, поэтому зависший поток данных не удерживает рабочий поток ОС, пока ждёт. Удерживают его две вещи: каждая попытка подключения разрешает имя хоста блокирующимgetaddrinfo(см. «Разрешение имён блокирует» в Исходящий HTTP; приSPINEL_WORKERS=1неработающий резолвер задерживает каждый запрос на тайм-аут резолвера, по разу на попытку на поток, а IP-адрес в URL позволяет обойтись без поиска) и вызов SQLite внутриon_messageдо своего возврата, как в задаче по расписанию (Фоновая работа): приSPINEL_WORKERS=1долгая запись задерживает каждый запрос. Для нагруженного потока вставляйте одну небольшую строку или собирайте сообщения в памяти и записывайте их пакетами из задачиScheduler.every. - Не реализовано. Расширения (
permessage-deflate) и подпротоколы не согласовываются. Нет поддержки прокси, редиректов и cookie, нет автоматического сохранения сообщений и нет сервера WebSocket (ActionCable). Текстовые фреймы не проверяются на допустимый UTF-8 (отступление от RFC 6455, раздел 8.1): байты передаются дальше в том виде, в каком пришли.Connectionдействителен только во время колбэка; отправка из запроса или задачи не поддерживается.
Для одного соединения без цикла переподключения (скрипт, однократное чтение) используйте синхронный клиент:
require "cybertrain/websocket"
require "cybertrain/net/tls" # только для wss://
client = Cybertrain::WebSocket::Client.connect("wss://feed.example.com/v1", timeout: 5)
client.send_text("hello")
msg = client.receive(10) # ждёт не более 10 с; nil, если ничего не пришло
msg.ws_opcode # :text или :binary
msg.ws_data # байты
client.close
Проектное решение: docs/design.md D33.
Отправка электронной почты
Cybertrain::Mailer отправляет электронные письма простым текстом через
SMTP-сервер (сервер отправки вашего провайдера или локальный ретранслятор).
Как и HTTP-клиент, он не входит в require "cybertrain", а TLS подключается
отдельным require:
require "cybertrain/mailer"
require "cybertrain/net/tls" # для starttls (по умолчанию) и tls; компонуется с системной libssl
# Настройки из CYBERTRAIN_SMTP_HOST, _PORT, _USER, _PASSWORD, _FROM, _TLS:
begin
Cybertrain::Mailer.deliver(to: user.email, subject: "Welcome", body: "Hello #{user.name},\n...")
rescue Cybertrain::Mailer::Error => e
Cybertrain.logger.warn("mail: #{e.message}") # никогда не содержит пароль
end
# Или явные настройки, в обход Config:
mailer = Cybertrain::Mailer.new(host: "smtp.example.com", port: 587, tls: "starttls",
user: "app", password: ENV["SMTP_PASSWORD"].to_s,
from: "noreply@example.com", timeout: 10, deadline: 60)
mailer.deliver(to: "a@example.com, b@example.com", subject: "Report", body: report)
| Переменная | Атрибут | По умолчанию |
|---|---|---|
CYBERTRAIN_SMTP_HOST |
smtp_host |
"" (Mailer.new возбуждает ArgumentError, пока она не задана) |
CYBERTRAIN_SMTP_PORT |
smtp_port |
0: 587 для starttls, 465 для tls, 25 для none |
CYBERTRAIN_SMTP_USER |
smtp_user |
"": без AUTH |
CYBERTRAIN_SMTP_PASSWORD |
smtp_password |
"" (секрет) |
CYBERTRAIN_SMTP_FROM |
smtp_from |
"" |
CYBERTRAIN_SMTP_TLS |
smtp_tls |
"starttls"; tls или none |
- Режимы TLS.
starttlsподключается открытым текстом, требует, чтобы сервер предлагал STARTTLS (иначеMailer::TLSError), и переходит на TLS прежде, чем сказано что-либо ещё; байты, которые сервер отправляет после своего ответа на STARTTLS, отвергаются.tlsговорит на TLS с первого байта (порт 465). Оба проверяют сертификат поhost, как HTTP-клиент (указывайте имя хоста, а не IP-адрес), и обоим нуженrequire "cybertrain/net/tls": без негоdeliverещё до подключения возбуждаетMailer::TLSError, который говорит об этом.none— открытый текст, для ретранслятора на той же машине или тестового сервера: при настроенном пользователе он отказывается (Mailer::InsecureError, до подключения), если только хост не разрешается в адрес loopback, и тогда набирает только его адреса loopback. - Пароль никогда не пишется в лог и не показывается. Его нет в стартовом
баннере, в
help, в логе и в сообщениях об ошибках; он отправляется только внутри команды AUTH PLAIN, поверх TLS (или на loopback). Не делайтеpилиinspectдляMailerилиConfig, которые его хранят. - Каждое ожидание ограничено, как в Исходящий HTTP:
подключение, каждый шаг TLS и каждый ответ ждут не более
timeoutсекунд (по умолчанию 10), вся доставка — не болееdeadlineсекунд (по умолчанию 60), иначеMailer::TimeoutError. Разрешение имён блокирует вызывающий поток. - Диалог: приветствие 220,
EHLO(имя —helo:, по умолчаниюlocalhost),STARTTLSи сноваEHLO,AUTH PLAIN, когда задан пользователь (сервер должен предлагать PLAIN),MAIL FROM, по одномуRCPT TOна получателя,DATA,QUIT. Ответ с другим кодом — этоMailer::RejectedError, чейsmtp_code— код сервера (421, 535, 550, ...), а сообщение содержит шаг и текст сервера. Возвращаемое значение — ответ сервера на сообщение ("250 2.0.0 Ok: queued as ..."). - Сообщение:
Date,From,To,Subject,MIME-Version: 1.0,Content-Type: text/plain; charset=UTF-8. Тема не в ASCII кодируется по RFC 2047 (=?UTF-8?B?...?=, с разбиением между символами). Концы строк тела становятся CRLF, а строки, начинающиеся с., дополняются точкой (dot-stuffing). ЕгоContent-Transfer-Encoding—7bitдля ASCII,8bit(сMAIL FROM ... BODY=8BITMIME) для UTF-8, когда сервер объявляет 8BITMIME, иbase64в остальных случаях или когда строка длиннее 998 байт.Message-IDне добавляется (серверы отправки добавляют его сами). - Что отвергается с
ArgumentErrorдо подключения: адрес, который не является голым ASCIIlocal@domain(без отображаемого имени, без<>, пробелов, запятых, кавычек, CR и LF; не более 254 байт), тема с CR, LF или NUL (внедрение заголовков) или длиннее 998 байт, тело, которое не является допустимым UTF-8 или содержит NUL, больше 100 получателей.to:принимает один адрес или несколько, разделённых запятыми. - Пока нет: HTML и multipart-сообщений, вложений, шаблонов, отображаемых
имён, заголовков
Cc/Bcc(вместо этого отправляйте нескольким получателям), AUTH LOGIN/CRAM-MD5, адресов SMTPUTF8, конвейеризации и очереди доставки (доставка выполняется в вызывающем потоке — зелёном потоке, как запрос).cybertrain generate authenticationпо-прежнему не пишет сброс пароля.
Проектное решение: docs/design.md D27.