SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-реализация проверки подключения к AI-провайдеру.
|
||||
/// </summary>
|
||||
public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
{
|
||||
/// <summary>
|
||||
/// Таймаут HTTP-запроса проверки в секундах; применяется DI-регистрацией клиента.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт проверку поверх HttpClient.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">Клиент с таймаутом 12 с (DI: AddHttpClient в Deal.Api).</param>
|
||||
public AiConnectionChecker(HttpClient httpClient)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpClient);
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<AiCheckResultDto> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user