Skip to content

Transport and Authentication RU

Алексей . edited this page Jul 26, 2026 · 1 revision

Транспорт и аутентификация

English Русский

Эта страница описывает слой, лежащий под LogsParserClient: контракт ILogsParserDataSource, реализацию LogsParserHttpDataSource, реактивный конечный автомат аутентификации, повторы, ограничение частоты запросов и React-защиту от DDoS. Её стоит открыть, когда запрос зависает, зацикливается или возвращает нечто непохожее на HTML с логами.

ILogsParserDataSource

Пространство имён LogsParser.Abstractions.

public interface ILogsParserDataSource
{
    Task<string> GetContentAsync(ParserRequest request, CancellationToken cancellationToken = default);
}

В интерфейсе ровно один член. LogsParserClient строит относительный URI, передаёт его в этот метод, получает сырую строку HTML и отдаёт её статическому парсеру — сам он никогда не работает с HttpClient, cookie или учётными данными. Следовательно, весь транспорт заменяем: достаточно реализовать интерфейс, и клиент продолжит работать поверх файлов, кеша, набора записанных фикстур или стороннего сервиса.

Член Тип Описание
GetContentAsync Task<string> Возвращает сырой HTML для request.RelativeUri. Вызывающая сторона не проверяет результат — парсеры принимают любую пришедшую строку.

ParserRequest

Пространство имён 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 и делегировать вызовы ему.

LogsParserHttpDataSource

Пространство имён LogsParser.Net. Единственная реализация, поставляемая с пакетом.

public sealed class LogsParserHttpDataSource : ILogsParserDataSource, IDisposable

Конструктор

public 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.

LogsParserHttpOptions

Пространство имён 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 ждать момента сброса лимита вместо исключения.

GetContentAsync

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, поэтому для чтения нужна ссылка на конкретный тип, а не на интерфейс.

Dispose

public void Dispose()

Освобождает созданный внутри HttpClient (а через disposeHandler: true — и его обработчик). Если HttpClient был передан извне, Dispose() ничего с ним не делает.

Передача собственного HttpClient

Предупреждение — всегда задавайте 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. Граница защищает от сервиса, который продолжает перенаправлять на вход, хотя сценарий входа сообщает об успехе.

TOTP

Библиотека реализует 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-защита от DDoS

Сайт закрыт React/vDDoS-щитом, который иногда отвечает на обычный запрос кодом 200 OK и страницей обфусцированного JavaScript вместо содержимого. Страница содержит головоломку AES-128-CBC: настоящий браузер выполняет скрипт, вычисляет токен, сохраняет его в cookie и перезагружает страницу.

Библиотека делает то же самое, причём автоматически — вызывать нечего и включать нечего:

  1. Обнаружение. Ответ 200 считается challenge, только если его заголовок server равен nginx, заголовок cache-control равен no-cache, а тело содержит /vddosw3data.js либо Please turn JavaScript on and reload the page. Эти литералы повторяют разметку реального сервиса.
  2. Решение. Из скрипта восстанавливаются ключ AES, IV и полезная нагрузка, после чего блок расшифровывается. Основной путь читает их из обфусцированного индексируемого массива; если эта форма не совпала, по очереди перебираются структурно допустимые тройки шестнадцатеричных значений.
  3. Сохранение. Полученный токен записывается в то же хранилище cookie, что и сессионные cookie, под точным именем R3ACTLB. Поэтому сохранение хранилища сохраняет токен щита вместе с сессией — см. Хранилище cookie.
  4. Повтор. Исходный запрос отправляется снова с новой 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 и счётчики ограничения частоты, чего обычно достаточно, чтобы различить эти случаи.

См. также

Clone this wiki locally