-
Notifications
You must be signed in to change notification settings - Fork 0
Transport and Authentication RU
Эта страница описывает слой, лежащий под LogsParserClient: контракт ILogsParserDataSource,
реализацию LogsParserHttpDataSource, реактивный конечный автомат аутентификации, повторы, ограничение частоты
запросов и React-защиту от DDoS. Её стоит открыть, когда запрос зависает, зацикливается или возвращает нечто
непохожее на HTML с логами.
Пространство имён LogsParser.Abstractions.
public interface ILogsParserDataSource
{
Task<string> GetContentAsync(ParserRequest request, CancellationToken cancellationToken = default);
}В интерфейсе ровно один член. LogsParserClient строит относительный URI, передаёт его в этот метод, получает
сырую строку HTML и отдаёт её статическому парсеру — сам он никогда не работает с HttpClient, cookie или учётными
данными. Следовательно, весь транспорт заменяем: достаточно реализовать интерфейс, и клиент продолжит работать
поверх файлов, кеша, набора записанных фикстур или стороннего сервиса.
| Член | Тип | Описание |
|---|---|---|
GetContentAsync |
Task<string> |
Возвращает сырой HTML для request.RelativeUri. Вызывающая сторона не проверяет результат — парсеры принимают любую пришедшую строку. |
Пространство имён LogsParser (корневое).
public sealed record ParserRequest(string RelativeUri, ICookieStorage? CookieStorage = null);| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
RelativeUri |
string |
— | Относительный URI со строкой запроса в том виде, в каком его создаёт LogsRequestUriBuilder. Разрешается относительно BaseAddress у HttpClient. |
CookieStorage |
ICookieStorage? |
null |
Хранилище, замещающее хранилище источника данных по умолчанию для одного этого запроса. null означает «использовать хранилище по умолчанию». |
Именно через хранилище на запрос один процесс может обслуживать несколько независимых сессий через один источник данных: см. Хранилище cookie.
Минимальная реализация, отдающая сохранённый HTML с диска вместо обращения к сети:
using LogsParser;
using LogsParser.Abstractions;
using LogsParser.Models;
public sealed class FileHtmlDataSource : ILogsParserDataSource
{
private readonly string _root;
public FileHtmlDataSource(string root) => _root = root;
public Task<string> GetContentAsync(ParserRequest request, CancellationToken cancellationToken = default)
{
var name = string.Concat(request.RelativeUri.Split(Path.GetInvalidFileNameChars()));
return File.ReadAllTextAsync(Path.Combine(_root, name + ".html"), cancellationToken);
}
}
var client = new LogsParserClient(new FileHtmlDataSource(@"C:\fixtures"));
var page = await client.GetLogsAsync(new LogsQuery(ServerId: 201));Ничего больше в библиотеке менять не требуется. Собственный источник — это ещё и самый простой способ добавить
кеширование, троттлинг или логирование перед реальным HTTP-источником: достаточно обернуть
LogsParserHttpDataSource и делегировать вызовы ему.
Пространство имён LogsParser.Net. Единственная реализация, поставляемая с пакетом.
public sealed class LogsParserHttpDataSource : ILogsParserDataSource, IDisposablepublic LogsParserHttpDataSource(
LogsParserCredentials? credentials = null,
ICookieStorage? cookieStorage = null,
LogsParserHttpOptions? options = null,
HttpClient? httpClient = null,
ILoggerFactory? loggerFactory = null)| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
credentials |
LogsParserCredentials? |
null |
Логин, пароль и секрет TOTP в Base32. Без них источник данных работает только со страницами, не требующими сессии, и выбрасывает AuthenticationRequiredException, как только сервис её запросит. |
cookieStorage |
ICookieStorage? |
null |
Хранилище cookie по умолчанию для всех запросов. Если не задано, создаётся новый MemoryCookieStorage. |
options |
LogsParserHttpOptions? |
null |
Параметры транспорта. Если не заданы, используется новый LogsParserHttpOptions() со значениями по умолчанию. |
httpClient |
HttpClient? |
null |
Клиент, принадлежащий вызывающему коду. Если не задан, источник данных создаёт собственный. |
loggerFactory |
ILoggerFactory? |
null |
Если не null, устанавливается в LogsParserLogging — см. Логирование. |
-
HttpClientне передан. Создаётсяnew HttpClientHandler { AllowAutoRedirect = false, AutomaticDecompression = DecompressionMethods.All }, оборачивается вHttpClientсdisposeHandler: true, и источник данных запоминает, что клиент принадлежит ему.Dispose()освобождает его. -
HttpClientпередан. Экземпляр используется как есть и не освобождается вDispose(); его временем жизни управляет тот, кто его создал. -
BaseAddressприсваивается через??=: значениеLogsParserHttpOptions.BaseUriприменяется, только если у клиента ещё нетBaseAddress. -
User-Agentдобавляется, только еслиDefaultRequestHeaders.UserAgentпуст, аAccept— только если пустDefaultRequestHeaders.Accept. Переданный клиент, у которого эти заголовки уже заданы, сохраняет свои значения, а соответствующие параметры молча игнорируются. -
loggerFactory, если передан, вызываетLogsParserLogging.UseLoggerFactory(loggerFactory)— это переключатель уровня процесса, а не настройка экземпляра. - Пишется одна строка уровня
Debugс базовым URI, признаком наличия учётных данных,MaxRetryAttempts, типом хранилища cookie и тем,internalилиexternalиспользуемыйHttpClient.
Пространство имён LogsParser.Models. Полное описание — в Моделях; здесь запись продублирована,
потому что все настройки, упомянутые на этой странице, живут именно в ней.
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
BaseUri |
Uri |
https://arizonarp.logsparser.info/ |
Применяется, только если у HttpClient нет BaseAddress. |
UserAgent |
string |
строка UA Chrome 128 для десктопа | Применяется, только если клиент не отправляет User-Agent. |
Accept |
string |
браузерная строка text/html,…
|
Применяется, только если клиент не отправляет Accept. |
MaxRetryAttempts |
int |
5 |
Верхняя граница числа попыток при временных сбоях. |
WaitForRateLimitReset |
bool |
true |
При 429 ждать момента сброса лимита вместо исключения. |
public async Task<string> GetContentAsync(ParserRequest request, CancellationToken cancellationToken = default)Один вызов может выполнить несколько HTTP-обменов: решение challenge, аутентификация и ожидание сброса лимита внутренне повторяют тот же самый запрос. Cookie считываются из хранилища в заголовки перед каждой отправкой и записываются обратно из заголовков ответа после неё, поэтому сессия сохраняется между вызовами.
Исключения:
| Исключение | Когда |
|---|---|
ArgumentNullException |
request равен null. |
AuthenticationRequiredException |
Сервис перенаправил на /login или /authenticator, а credentials равнялись null. |
AuthenticationFailedException |
Страница входа недоступна, учётные данные отклонены либо ветка входа отработала 3 раза без стабилизации. |
TwoFactorAuthenticationException |
Код TOTP отклонён, секрет пуст или не является Base32, нужный часовой пояс отсутствует либо ветка 2FA отработала 3 раза без стабилизации. |
AccountConfigurationException |
Сервис перенаправил на /profile. |
CsrfTokenNotFoundException |
На странице, загруженной при аутентификации, не оказалось <meta name="csrf-token" content="…">. |
RateLimitExceededException |
429 при WaitForRateLimitReset = false либо исчерпание попыток при откате от 429, когда момент сброса неизвестен. |
ReactShieldBypassException |
Полезную нагрузку challenge не удалось расшифровать либо сервис продолжал выдавать challenge после 3 решённых токенов. |
LogsParserHttpException |
Любой другой неуспешный код состояния либо временный сбой, переживший MaxRetryAttempts (исходное исключение попадает в InnerException). |
OperationCanceledException |
Токен отменён либо цикл повторов завершился, не получив содержимого. |
Все они, кроме ArgumentNullException и OperationCanceledException, наследуются от LogsParserException — см.
Исключения.
public int RateLimitMax { get; private set; }public int RateLimitRemaining { get; private set; }public DateTimeOffset? RateLimitReset { get; private set; }Они объявлены в классе, а не в ILogsParserDataSource, поэтому для чтения нужна ссылка на конкретный тип, а не на
интерфейс.
public void Dispose()Освобождает созданный внутри HttpClient (а через disposeHandler: true — и его обработчик). Если HttpClient
был передан извне, Dispose() ничего с ним не делает.
Предупреждение — всегда задавайте
AllowAutoRedirect = false. Весь протокол аутентификации считывается из ответов302: библиотека смотрит наLocationи решает, выполнять вход, переподтверждать второй фактор или завершиться ошибкой. Обработчик, следующий за перенаправлениями автоматически, разрешает эти302внутриHttpClient, и библиотека их просто не видит. Вызов при этом завершается успешно и возвращает HTML страницы входа как будто это содержимое — без исключения, без предупреждения, лишь парсеры ничего не находят. Обработчик, создаваемый библиотекой по умолчанию, задаётAllowAutoRedirect = false; любой переданный клиент обязан делать то же самое.
Обычная причина передавать собственный клиент — прокси:
using System.Net;
using LogsParser;
using LogsParser.Models;
using LogsParser.Net;
var handler = new HttpClientHandler
{
AllowAutoRedirect = false, // обязательно
AutomaticDecompression = DecompressionMethods.All,
Proxy = new WebProxy("socks5://127.0.0.1:1488"),
UseProxy = true
};
using var httpClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(60)
};
using var dataSource = new LogsParserHttpDataSource(
credentials: new LogsParserCredentials("my_login", "my_password", "BASE32SECRET"),
options: new LogsParserHttpOptions { MaxRetryAttempts = 3 },
httpClient: httpClient);
var client = new LogsParserClient(dataSource);Здесь BaseAddress, User-Agent и Accept по-прежнему берутся из параметров, потому что этот клиент их не
задаёт. Если задать httpClient.BaseAddress самостоятельно, значение LogsParserHttpOptions.BaseUri окажется
бесполезным. В этом примере httpClient освобождается оператором using, а не источником данных.
Здесь нет ни LoginAsync, ни SignInAsync, ни шага «подключиться». Аутентификация запускается потому, что запрос
данных вернулся перенаправлением: источник данных распознаёт цель, выполняет соответствующий сценарий и повторяет
исходный запрос. Поэтому первый вызов в процессе прозрачно выполняет вход, а последующие не делают ничего лишнего,
пока сервис не потребует обратного.
Применяется к ответу 302 Found на запрошенный URI и определяется значением Location.AbsolutePath:
Location |
Что делает библиотека |
|---|---|
/login |
Полный вход. GET /login, извлечение токена из <meta name="csrf-token" content="…">, POST /login с полями _token, name и password, затем сразу переход к подтверждению TOTP. Ответ с перенаправлением обратно на /login означает, что учётные данные отклонены → AuthenticationFailedException. |
/authenticator |
Только переподтверждение TOTP. GET /authenticator, получение свежего токена CSRF, POST /authenticator с полями _token и code. Пароль не отправляется повторно — это сценарий середины сессии, когда первый фактор ещё действителен, но окно подтверждения 2FA истекло. Ответ с перенаправлением обратно на /authenticator означает, что код отклонён → TwoFactorAuthenticationException. |
/profile |
AccountConfigurationException. Учётная запись существует, но не настроена на сайте; повторы здесь не помогут. |
Если учётные данные не переданы, обе ветки — /login и /authenticator — выбрасывают
AuthenticationRequiredException, не выполняя никакой работы.
Одна деталь ветки 2FA: если сам GET /authenticator отвечает перенаправлением, форма не отдаётся — либо первый
фактор утрачен (/login), либо сессия уже подтверждена, — поэтому ничего не отправляется, а исходный запрос
просто повторяется, снова попадая в конечный автомат с новым сигналом.
Ветки /login и /authenticator разделяют один счётчик с жёстко заданной границей в 3 попытки. При её
превышении ветка входа выбрасывает AuthenticationFailedException, а ветка 2FA —
TwoFactorAuthenticationException. Граница защищает от сервиса, который продолжает перенаправлять на вход, хотя
сценарий входа сообщает об успехе.
Библиотека реализует RFC 6238 самостоятельно: декодирование Base32 → HMAC-SHA1 по 30-секундному счётчику →
динамическое усечение → 6-значный код. Она принимает строку секрета в Base32 и ничего больше: QR-коды,
URI вида otpauth:// и payload-ы миграции Google Authenticator не разбираются — извлеките секрет самостоятельно и
передайте его как LogsParserCredentials.TotpSecret.
var credentials = new LogsParserCredentials(
Login: "my_login",
Password: "my_password",
TotpSecret: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ");Код формируется по собственному часовому поясу сайта, который разрешается как America/Juneau с запасным
Windows-идентификатором Alaskan Standard Time. Если на хосте нет ни одного из них, генерация выбрасывает
TwoFactorAuthenticationException, а не откатывается молча к UTC, — контейнер без данных о часовых поясах падает
явно, вместо того чтобы выдавать коды, которые сайт отвергает. Пустой секрет и секрет с символами вне Base32
приводят к тому же исключению.
Внутри одного вызова GetContentAsync действуют три независимые границы:
| Граница | Значение | К чему относится |
|---|---|---|
LogsParserHttpOptions.MaxRetryAttempts |
5 по умолчанию |
Временные сбои — произвольные исключения при отправке и откат по 429, когда момент сброса неизвестен. |
| Попытки аутентификации |
3, жёстко задано |
Общий счётчик веток /login и /authenticator. |
| Попытки решения challenge |
3, жёстко задано |
Решение React-challenge. |
Временные сбои откатываются экспоненциально: после n-го сбоя цикл ждёт 2^n секунд (2 с, 4 с, 8 с, …) и
отправляет запрос снова. Когда попытки исчерпаны, последнее исключение оборачивается в LogsParserHttpException,
в сообщении которого указаны URI и число попыток.
Две вещи намеренно не расходуют попытку повтора: решение React-challenge и ожидание сброса лимита, когда момент
сброса известен. Оба состояния ожидаемы и самоустраняются, а не являются сбоями; если бы они списывались со счёта
MaxRetryAttempts, период высокой нагрузки исчерпал бы бюджет ещё до появления настоящей ошибки.
Любое исключение, наследующее LogsParserException, немедленно пробрасывается наружу и никогда не повторяется.
Сюда входят все исключения из таблицы выше. Новое доменное исключение, не наследующее LogsParserException, было
бы принято за временный сбой и молча повторено MaxRetryAttempts раз.
Отмена также никогда не повторяется: OperationCanceledException пробрасывается как есть.
Каждый ответ на запрошенный URI обновляет три свойства по заголовкам ответа:
| Заголовок | Свойство | Тип | Примечания |
|---|---|---|---|
X-Ratelimit-Limit |
RateLimitMax |
int |
Разрешённое число запросов в окне. |
X-Ratelimit-Remaining |
RateLimitRemaining |
int |
Сколько запросов осталось в текущем окне. |
X-Ratelimit-Reset |
RateLimitReset |
DateTimeOffset? |
Unix-секунды; применяется, только если значение разобрано и больше нуля. |
Отсутствующий или неразбираемый заголовок оставляет соответствующее свойство с прежним значением, поэтому свойства всегда показывают последнее наблюдение, а не сбрасываются в ноль.
При 429 Too Many Requests:
-
WaitForRateLimitReset = true(по умолчанию). ЕслиRateLimitResetизвестен, цикл спит до этого момента плюс одна секунда и повторяет запрос без расхода попытки. Если неизвестен, используется заголовокRetry-After, а при его отсутствии —2^(попытка+1)секунд; этот запасной путь попытку расходует и по их исчерпании выбрасываетRateLimitExceededException. -
WaitForRateLimitReset = false. Немедленно выбрасываетсяRateLimitExceededException.
RateLimitExceededException (пространство имён LogsParser.Exceptions) несёт с собой ту паузу, которую
использовал бы цикл:
| Свойство | Тип | Описание |
|---|---|---|
RetryAfterSeconds |
int |
Секунды, полученные из Retry-After, либо экспоненциальное запасное значение. |
ResetAt |
DateTimeOffset? |
Значение RateLimitReset на момент сбоя, если оно известно. |
Вежливый клиент проверяет остаток бюджета между вызовами, не дожидаясь 429:
using var dataSource = new LogsParserHttpDataSource(
credentials: credentials,
options: new LogsParserHttpOptions { WaitForRateLimitReset = false });
var client = new LogsParserClient(dataSource);
foreach (var serverId in serverIds)
{
if (dataSource.RateLimitRemaining <= 1 && dataSource.RateLimitReset is { } resetAt)
{
var pause = resetAt - DateTimeOffset.UtcNow + TimeSpan.FromSeconds(1);
if (pause > TimeSpan.Zero)
{
await Task.Delay(pause);
}
}
var page = await client.GetLogsAsync(new LogsQuery(ServerId: serverId));
Console.WriteLine($"{serverId}: {page.Entries.Count} entries, {dataSource.RateLimitRemaining}/{dataSource.RateLimitMax} left");
}Сайт закрыт React/vDDoS-щитом, который иногда отвечает на обычный запрос кодом 200 OK и страницей
обфусцированного JavaScript вместо содержимого. Страница содержит головоломку AES-128-CBC: настоящий браузер
выполняет скрипт, вычисляет токен, сохраняет его в cookie и перезагружает страницу.
Библиотека делает то же самое, причём автоматически — вызывать нечего и включать нечего:
-
Обнаружение. Ответ
200считается challenge, только если его заголовокserverравенnginx, заголовокcache-controlравенno-cache, а тело содержит/vddosw3data.jsлибоPlease turn JavaScript on and reload the page.Эти литералы повторяют разметку реального сервиса. - Решение. Из скрипта восстанавливаются ключ AES, IV и полезная нагрузка, после чего блок расшифровывается. Основной путь читает их из обфусцированного индексируемого массива; если эта форма не совпала, по очереди перебираются структурно допустимые тройки шестнадцатеричных значений.
-
Сохранение. Полученный токен записывается в то же хранилище cookie, что и сессионные cookie, под точным
именем
R3ACTLB. Поэтому сохранение хранилища сохраняет токен щита вместе с сессией — см. Хранилище cookie. - Повтор. Исходный запрос отправляется снова с новой cookie и не расходует попытку повтора.
В расшифрованном тексте ничто не указывает, что это именно нужный токен, поэтому решённый токен нельзя проверить
до использования: неверный просто приводит к очередному challenge. Именно поэтому у ветки challenge есть
собственная граница в 3 попытки — без неё сервис, продолжающий выдавать challenge, крутил бы цикл вечно. На
четвёртом challenge вызов выбрасывает ReactShieldBypassException. То же исключение возникает, если полезную
нагрузку вообще не удалось расшифровать или на странице нашлось меньше трёх различных строковых литералов для
перебора.
Сам ReactShieldBypass — внутренняя деталь реализации и не входит в публичный API.
| Симптом | Причина | Что делать |
|---|---|---|
| Вызов успешен, но содержимое — страница входа; парсеры возвращают пустой результат | Передан HttpClient, обработчик которого следует за перенаправлениями — библиотека не видит 302
|
Задать AllowAutoRedirect = false у обработчика |
AuthenticationRequiredException |
Сервис запросил сессию, а credentials равнялись null
|
Передать LogsParserCredentials в конструктор или через Внедрение зависимостей
|
AuthenticationFailedException на первом запросе |
Логин или пароль отклонены (перенаправление обратно на /login) либо страница входа не загрузилась |
Проверить учётные данные на сайте в браузере |
TwoFactorAuthenticationException с упоминанием часовых поясов |
На хосте нет ни America/Juneau, ни Alaskan Standard Time — типично для облегчённых Linux-контейнеров |
Установить базу часовых поясов (например, пакет tzdata) |
TwoFactorAuthenticationException при отклонённом коде |
Неверный секрет Base32 или расхождение системных часов | Скопировать секрет заново; синхронизировать системное время |
AccountConfigurationException |
Сервис перенаправил на /profile: учётная запись не настроена на сайте |
Завершить настройку учётной записи в браузере; повторы не помогут |
ReactShieldBypassException |
Щит продолжал выдавать challenge либо формат его полезной нагрузки изменился | Повторить позже; если повторяется — формат challenge изменился и решателю нужно обновление |
| Вызовы подолгу простаивают, в логах предупреждения об ожидании | Ответы 429 при WaitForRateLimitReset = true — это штатное поведение |
Снизить частоту запросов либо задать WaitForRateLimitReset = false и обрабатывать RateLimitExceededException
|
LogsParserHttpException, обёрнувший TaskCanceledException
|
Сетевые сбои или срабатывание HttpClient.Timeout, повторённые до исчерпания попыток |
Увеличить таймаут, проверить прокси, изучить InnerException
|
| В логах вообще ничего нет | Фабрика логгеров не установлена | Передать loggerFactory в конструктор — см. Логирование
|
Уровень логирования Trace показывает каждую отправку с кодом состояния, имена задействованных cookie и счётчики
ограничения частоты, чего обычно достаточно, чтобы различить эти случаи.
LogsParser · CC BY-NC 4.0 — non-commercial use only / только некоммерческое использование
English
- Home
- Getting Started
- LogsParserClient
- Requests and URI Builder
- Models
- Parsers
- Transport and Authentication
- Cookie Storage
- Dependency Injection
- Logging
- Exceptions
Русский
- Главная
- Начало работы
- LogsParserClient
- Запросы и построитель URI
- Модели
- Парсеры
- Транспорт и авторизация
- Хранение cookies
- Внедрение зависимостей
- Логирование
- Исключения