using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
using Deal.Infrastructure.Integrations.Extensions;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Infrastructure.Integrations.Services;
public sealed class AiConnectionChecker : IAiConnectionChecker
{
///
/// Таймаут HTTP-запроса проверки в секундах; применяется DI-регистрацией клиента.
///
public const int RequestTimeoutSeconds = 12;
private const string LocalServerMessageTemplate = "Локальный сервер «{0}» (ping в проде)";
// Сообщение ветки «API-ключ не задан».
private const string NoApiKeyMessage = "Не задан API-ключ";
// Сообщение успешной проверки (HTTP < 400).
private const string SuccessMessage = "Подключение успешно";
// Сообщение ветки «ключ не принят» (HTTP 401/403).
private const string KeyRejectedMessageTemplate = "Ключ не принят (HTTP {0}) — проверьте ключ и доступ к модели";
// Сообщение ветки «иной HTTP-код» (≥ 400, кроме 401/403).
private const string HttpErrorMessageTemplate = "HTTP {0} — проверьте Base URL и модель";
// Сообщение ветки сетевого сбоя (деталь — текст исключения).
private const string ConnectionErrorMessageTemplate = "Ошибка соединения: {0}";
// Сообщение ветки таймаута HttpClient.
private const string TimeoutMessage = "Ошибка соединения: превышен таймаут ожидания";
// Сообщение SSRF-гейта: провайдер вне фиксированного каталога (allowlist).
private const string ProviderNotAllowedMessage = "Провайдер не из списка разрешённых";
// Сообщение SSRF-гейта: base URL не абсолютный http(s).
private const string InvalidBaseUrlMessage = "Недопустимый Base URL (ожидается http/https)";
// Значение api_style провайдера Anthropic (AiProviderDefinition.ApiStyle).
private const string AnthropicApiStyle = "anthropic";
// Путь списка моделей Anthropic: {base}/v1/models.
private const string AnthropicModelsPath = "/v1/models";
// Путь списка моделей OpenAI-совместимых: {base}/models.
private const string OpenAiModelsPath = "/models";
// Сообщение SSRF-гейта: base URL указывает на приватный/локальный адрес.
private const string PrivateEndpointNotAllowedMessage =
"Недопустимый Base URL (приватный/локальный адрес недоступен для проверки)";
// Заголовок ключа Anthropic.
private const string ApiKeyHeaderName = "x-api-key";
// Заголовок версии протокола Anthropic.
private const string AnthropicVersionHeaderName = "anthropic-version";
// Значение версии протокола Anthropic.
private const string AnthropicVersionHeaderValue = "2023-06-01";
private readonly HttpClient _httpClient;
///
/// Создаёт проверку поверх HttpClient.
///
/// Клиент с таймаутом 12 с (DI: AddHttpClient в Deal.Api).
public AiConnectionChecker(HttpClient httpClient)
{
ArgumentNullException.ThrowIfNull(httpClient);
_httpClient = httpClient;
}
async Task IAiConnectionChecker.CheckAsync(AiCheckRequest request, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == request.ProviderId);
string name = meta?.Name ?? request.ProviderId;
// SSRF-гейт (allowlist, preflight): проверка возможна только для провайдера фиксированного
// каталога AiProviders. В штатном потоке недостижимо (PATCH-гейт aiProvider/aiConfigs в
// SettingsService) — защита от ручного изменения БД/повреждённого хранилища.
if (meta is null)
{
return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage);
}
if (request.IsLocal)
{
return BuildResult(request, name, ok: true, message: string.Format(LocalServerMessageTemplate, name));
}
if (string.IsNullOrEmpty(request.ApiKey))
{
return BuildResult(request, name, ok: false, message: NoApiKeyMessage);
}
if (!TryBuildModelsUri(request.BaseUrl, request.ApiStyle, out Uri? modelsUri))
{
return BuildResult(request, name, ok: false, message: InvalidBaseUrlMessage);
}
// SSRF-гейт (Security review): проверка подключения выполняется только к публичным адресам.
// Private/loopback/link-local литералы и localhost запрещены для не-local провайдеров (локальные
// провайдеры — ветка IsLocal выше, HTTP для них не выполняется вовсе). DNS-имена не резолвятся
// здесь (полный egress-контроль с резолвом — на уровне сетевого периметра/прокси).
if (modelsUri.IsPrivateEndpoint())
{
return BuildResult(request, name, ok: false, message: PrivateEndpointNotAllowedMessage);
}
try
{
using HttpRequestMessage httpRequest = new(HttpMethod.Get, modelsUri);
AddAuthHeaders(httpRequest, request.ApiKey, request.ApiStyle);
using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest, ct);
int statusCode = (int)response.StatusCode;
if (statusCode < 400)
{
return BuildResult(request, name, ok: true, message: SuccessMessage);
}
if (statusCode is 401 or 403)
{
return BuildResult(request, name, ok: false,
message: string.Format(KeyRejectedMessageTemplate, statusCode));
}
return BuildResult(request, name, ok: false,
message: string.Format(HttpErrorMessageTemplate, statusCode));
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return BuildResult(request, name, ok: false, message: TimeoutMessage);
}
catch (HttpRequestException exception)
{
return BuildResult(request, name, ok: false,
message: string.Format(ConnectionErrorMessageTemplate, ConnectionErrorDetail(exception)));
}
}
private static AiCheckResultDto BuildResult(
AiCheckRequest request,
string name,
bool ok,
string message)
{
return new AiCheckResultDto(
Ok: ok,
Message: message,
Provider: request.ProviderId,
Name: name,
Base: request.BaseUrl,
Model: request.Model,
Local: request.IsLocal,
KeySet: !string.IsNullOrEmpty(request.ApiKey),
KeyMasked: MaskKey(request.ApiKey));
}
private static string MaskKey(string key)
{
if (string.IsNullOrEmpty(key))
{
return string.Empty;
}
if (key.Length <= 8)
{
return string.Concat(key.AsSpan(0, 1), "…");
}
return string.Concat(key.AsSpan(0, 4), "…", key.AsSpan(key.Length - 4));
}
private static bool TryBuildModelsUri(
string baseUrl,
string? apiStyle,
[NotNullWhen(true)] out Uri? modelsUri)
{
modelsUri = null;
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? parsed)
|| (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
{
return false;
}
string root = baseUrl.TrimEnd('/');
string relativePath = apiStyle == AnthropicApiStyle ? AnthropicModelsPath : OpenAiModelsPath;
if (!Uri.TryCreate(root + relativePath, UriKind.Absolute, out Uri? endpoint))
{
return false;
}
modelsUri = endpoint;
return true;
}
// Заголовки аутентификации: Bearer (OpenAI-совместимые) или x-api-key + версия (Anthropic).
// httpRequest: Запрос списка моделей.
// apiKey: Ключ открытым текстом (непустой — ветка ключа пройдена).
// apiStyle: Стиль API провайдера.
private static void AddAuthHeaders(
HttpRequestMessage httpRequest,
string apiKey,
string? apiStyle)
{
if (apiStyle == AnthropicApiStyle)
{
httpRequest.Headers.TryAddWithoutValidation(ApiKeyHeaderName, apiKey);
httpRequest.Headers.TryAddWithoutValidation(AnthropicVersionHeaderName, AnthropicVersionHeaderValue);
return;
}
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
}
// Исключение HTTP-слоя.
// Возвращает: Человекочитаемый текст причины.
private static string ConnectionErrorDetail(HttpRequestException exception)
{
if (!string.IsNullOrWhiteSpace(exception.Message))
{
return exception.Message;
}
return exception.InnerException?.Message ?? exception.GetType().Name;
}
}