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; } }