diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..2bf22d9 --- /dev/null +++ b/.env.template @@ -0,0 +1 @@ +SERVICE_AUTH_SECRET=change-me-to-a-long-random-string diff --git a/OBSERVABILITY.md b/OBSERVABILITY.md index a7431dc..88d9687 100644 --- a/OBSERVABILITY.md +++ b/OBSERVABILITY.md @@ -7,10 +7,16 @@ на своём эндпоинте `/metrics`, а Prometheus скрейпит их напрямую. - Scrapper: `http://scrapper:8081/metrics` -- Bot: `http://bot:8011/metrics` (отдельный Kestrel-эндпоинт, наружу не публикуется) +- Bot: `http://bot:8011/metrics` (отдельный Kestrel-эндпоинт) +- AI-Agent: `http://aiagent:8102/metrics` (отдельный Kestrel-эндпоинт) -Эндпоинт `/metrics` исключён из общего rate limiter — иначе скрейп раз в 15 секунд -конкурировал бы за лимит с прикладным трафиком и метрики выглядели бы «пропавшими». +Порты приложений не публикуются на хост — Prometheus скрейпит цели изнутри +сети compose. + +Rate limiter применяется точечно, политикой `public-api` на прикладных маршрутах, +поэтому `/metrics` и gRPC-сервисы под лимит не попадают в принципе. +Партиционирование идёт по `Tg-Chat-Id`, а не по IP: в Docker весь трафик бота +приходит с одного адреса, и лимит по IP отсекал бы легитимные запросы. Имена серий, которые видит Prometheus, зафиксированы тестом `MetricsEndpointTests` — экспортёр переименовывает инструменты, и панели Grafana @@ -23,7 +29,7 @@ | Метрика | Тип | Лейблы | Описание | |---|---|---|---| | `links_on_track_total` | Gauge | `tracked_source` | Количество ссылок в БД на мониторинге | -| `api_requests_total` | Counter | `source` | Счётчик запросов к API | +| `api_requests_total` | Counter | `source` | Счётчик запросов к API (лейбл — шаблон маршрута, не сырой путь) | | `request_duration_ms_total` | Histogram | `scope`, `scope_type` | Длительность операции в мс (RED: Duration) | | `errors_total` | Counter | `scope`, `scope_type`, `reason` | **Ошибки (RED: Errors)** | | `sent_updates_total` | Counter | — | Количество обновлений, отправленных в Bot | @@ -58,12 +64,13 @@ Настраивается на стороне Prometheus в `monitoring/prometheus.yml` — приложениям никакой конфигурации телеметрии не требуется. Лейбл `job` берётся из имени scrape-job -(`scrapper` / `bot`), `instance` — из адреса цели. +(`scrapper` / `bot` / `aiagent`), `instance` — из адреса цели. | Job | Цель | Интервал | |---|---|---| | `scrapper` | `scrapper:8081` | 15s (`global.scrape_interval`) | | `bot` | `bot:8011` | 15s (`global.scrape_interval`) | +| `aiagent` | `aiagent:8102` | 15s (`global.scrape_interval`) | ## Grafana diff --git a/README.md b/README.md index cdab924..acd5f08 100644 --- a/README.md +++ b/README.md @@ -61,15 +61,23 @@ cp src/LinkTracker.Bot.Api/.env.template src/LinkTracker.Bot.Api/.env cp src/LinkTracker.Scrapper.Api/.env.template src/LinkTracker.Scrapper.Api/.env ``` 4. Поставить действительные параметры в .env. -5. Создать копию [.env.template](src/LinkTracker.AiAgent/.env.template) с именем .env в каталоге ии-агента. +5. Создать копию [.env.template](src/LinkTracker.AiAgent.Api/.env.template) с именем .env в каталоге ии-агента. ``` cp src/LinkTracker.AiAgent.Api/.env.template src/LinkTracker.AiAgent.Api/.env ``` 6. Поставить действительные параметры в .env. +7. Задать общий сервисный секрет. Bot и Scrapper аутентифицируют друг друга по нему, + поэтому значение должно совпадать в обоих сервисах. +``` +cp .env.template .env +``` +8. Поставить в корневой .env своё значение `SERVICE_AUTH_SECRET`, а в + `src/LinkTracker.Bot.Api/.env` и `src/LinkTracker.Scrapper.Api/.env` — то же самое + значение в `ServiceAuth__Secret` (нужно для локального запуска без Docker). ### Для локального запуска -7. В [bot.Api.appesettings](src/LinkTracker.Bot.Api/appsettings.json), [scrapper.Api.appsettings](src/LinkTracker.Scrapper.Api/appsettings.json) и [aiagent.appsettings](src/LinkTracker.AiAgent.Api/appsettings.json) в ветках Scrapper, Bot и AiAgent соответственно выбрать валидные параметры. -8. Выполнить поочердено запуск сначала [docker-compose](docker-compose.yml), [Scrapper](src/LinkTracker.Scrapper.Api/Program.cs), [Bot](src/LinkTracker.Bot.Api/Program.cs) и [AiAgent](src/LinkTracker.AiAgent.Api/Program.cs) с помощью команд. +9. В [bot.Api.appsettings](src/LinkTracker.Bot.Api/appsettings.json), [scrapper.Api.appsettings](src/LinkTracker.Scrapper.Api/appsettings.json) и [aiagent.Api.appsettings](src/LinkTracker.AiAgent.Api/appsettings.json) в ветках Scrapper, Bot и AiAgent соответственно выбрать валидные параметры. +10. Выполнить поочердено запуск сначала [docker-compose](docker-compose.yml), [Scrapper](src/LinkTracker.Scrapper.Api/Program.cs), [Bot](src/LinkTracker.Bot.Api/Program.cs) и [AiAgent](src/LinkTracker.AiAgent.Api/Program.cs) с помощью команд. ``` docker compose -f docker-compose.yml up dotnet run --project src/LinkTracker.Scrapper.Api @@ -78,8 +86,8 @@ dotnet run --project src/LinkTracker.AiAgent.Api ``` ### Для запуска в контейнерах -7. В [bot.Docker.appsettings](src/LinkTracker.Bot.Api/appsettings.Docker.json), [scrapper.Docker.appsettings](src/LinkTracker.Scrapper.Api/appsettings.Docker.json) и [aiagent.Docker.appesettings](src/LinkTracker.AiAgent.Api/appsettings.Docker.json) в ветках Scrapper, Bot и AiAgent соответственно выбрать валидные параметры. -8. Выполнить поочердено запуск сначала [docker-compose](docker-compose.yml), [Scrapper](src/LinkTracker.Scrapper.Api/Program.cs), а затем [Bot](src/LinkTracker.Bot.Api/Program.cs) с помощью команд. +9. В [bot.Docker.appesettings](src/LinkTracker.Bot.Api/appsettings.Docker.json), [scrapper.Docker.appsettings](src/LinkTracker.Scrapper.Api/appsettings.Docker.json) и [aiagent.Docker.appesettings](src/LinkTracker.AiAgent.Api/appsettings.Docker.json) в ветках Scrapper, Bot и AiAgent соответственно выбрать валидные параметры. +10. Выполнить поочердено запуск сначала [docker-compose](docker-compose.yml), [Scrapper](src/LinkTracker.Scrapper.Api/Program.cs), а затем [Bot](src/LinkTracker.Bot.Api/Program.cs) с помощью команд. ``` docker compose -f docker-compose.yml -f docker-compose.apps.yml up ``` diff --git a/docker-compose.apps.yml b/docker-compose.apps.yml index 80c845c..dba379d 100644 --- a/docker-compose.apps.yml +++ b/docker-compose.apps.yml @@ -1,3 +1,6 @@ +x-service-auth-env: &service-auth-env + ServiceAuth__Secret: "${SERVICE_AUTH_SECRET:?SERVICE_AUTH_SECRET must be set in the root .env file}" + services: scrapper: build: @@ -15,12 +18,13 @@ services: condition: service_started valkey-cluster-init: condition: service_completed_successfully - ports: - - "8081:8081" - - "8082:8082" + expose: + - "8081" + - "8082" env_file: - src/LinkTracker.Scrapper.Api/.env environment: + <<: *service-auth-env ASPNETCORE_ENVIRONMENT: "Docker" bot: @@ -37,13 +41,14 @@ services: condition: service_completed_successfully schema-registry: condition: service_started - ports: - - "8091:8091" - - "8092:8092" - - "8011:8011" + expose: + - "8091" + - "8092" + - "8011" env_file: - src/LinkTracker.Bot.Api/.env environment: + <<: *service-auth-env ASPNETCORE_ENVIRONMENT: "Docker" aiagent: @@ -64,10 +69,10 @@ services: condition: service_healthy kafka-3: condition: service_healthy - ports: - - "8101:8101" - - "8102:8102" + expose: + - "8101" + - "8102" env_file: - src/LinkTracker.AiAgent.Api/.env environment: - ASPNETCORE_ENVIRONMENT: "Docker" \ No newline at end of file + ASPNETCORE_ENVIRONMENT: "Docker" diff --git a/src/LinkTracker.AiAgent.Api/Program.cs b/src/LinkTracker.AiAgent.Api/Program.cs index 4368cf6..417d694 100644 --- a/src/LinkTracker.AiAgent.Api/Program.cs +++ b/src/LinkTracker.AiAgent.Api/Program.cs @@ -2,11 +2,13 @@ using LinkTracker.AiAgent.Infrastructure.Clients.Registration; using LinkTracker.AiAgent.Infrastructure.Telemetry.Registration; using LinkTracker.EnvReader; +using LinkTracker.Shared.Infrastructure.Logging; using LinkTracker.Shared.Infrastructure.Telemetry; var builder = WebApplication.CreateBuilder(args); builder.AddLocalDotEnv(); +builder.AddSharedSerilog("aiagent"); builder.Services.AddAiAgentApplication(); builder.Services.AddAiAgentInfrastructure(builder.Configuration); diff --git a/src/LinkTracker.Bot.Api/.env.template b/src/LinkTracker.Bot.Api/.env.template index ec44f24..6e9b560 100644 --- a/src/LinkTracker.Bot.Api/.env.template +++ b/src/LinkTracker.Bot.Api/.env.template @@ -1 +1,2 @@ -Bot__Token=123:ABC \ No newline at end of file +Bot__Token=123:ABC +ServiceAuth__Secret=change-me-to-a-long-random-string diff --git a/src/LinkTracker.Bot.Api/Program.cs b/src/LinkTracker.Bot.Api/Program.cs index badfb56..243d7cd 100644 --- a/src/LinkTracker.Bot.Api/Program.cs +++ b/src/LinkTracker.Bot.Api/Program.cs @@ -2,7 +2,6 @@ using LinkTracker.Bot.Application.Dialogs.Registration; using LinkTracker.Bot.Application.Routing.Registration; using LinkTracker.Bot.Infrastructure.Clients.Registration; -using LinkTracker.Bot.Infrastructure.Logging; using LinkTracker.Bot.Infrastructure.Storage.Registration; using LinkTracker.Bot.Infrastructure.Telemetry.Registration; using LinkTracker.Bot.Presentation.BotApi.Endpoints; @@ -10,19 +9,22 @@ using LinkTracker.Bot.Presentation.Grpc; using LinkTracker.Bot.Presentation.Telegram.Registration; using LinkTracker.EnvReader; +using LinkTracker.Shared.Infrastructure.Authentication; +using LinkTracker.Shared.Infrastructure.Logging; using LinkTracker.Shared.Infrastructure.RateLimiting; using LinkTracker.Shared.Infrastructure.Resilience; using LinkTracker.Shared.Infrastructure.Telemetry; -using Serilog; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddSerilog((sp, lc) => SerilogConfig.Configure(lc)); builder.AddLocalDotEnv(); +builder.AddSharedSerilog("bot"); builder.Services.AddGrpc(); builder.Services.AddHttpResilienceOptions(builder.Configuration); -builder.Services.AddIpRateLimiting(builder.Configuration); +builder.Services.AddApiRateLimiting(builder.Configuration); +builder.Services.AddServiceAuthentication(builder.Configuration); +builder.Services.AddServiceAuthClients(builder.Configuration); builder.Services.AddCommands(); builder.Services.AddUpdateRouting(); @@ -36,17 +38,18 @@ var app = builder.Build(); +app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); app.UseRateLimiter(); -app.MapBotApi(); -app.MapGrpcService(); - -try -{ - app.MapMetricsEndpoint().RequireHost("*:8011"); - await app.RunAsync(); -} -finally -{ - Log.CloseAndFlush(); -} \ No newline at end of file +app.MapBotApi() + .RequireServiceAuthorization() + .RequireRateLimiting(RateLimitingPolicies.PublicApi); + +app.MapGrpcService() + .RequireServiceAuthorization(); + +app.MapMetricsEndpoint().RequireHost("*:8011"); + +await app.RunAsync(); diff --git a/src/LinkTracker.Bot.Application/Dialogs/Runtime/DialogManager.cs b/src/LinkTracker.Bot.Application/Dialogs/Runtime/DialogManager.cs index 95340ff..6cbd29f 100644 --- a/src/LinkTracker.Bot.Application/Dialogs/Runtime/DialogManager.cs +++ b/src/LinkTracker.Bot.Application/Dialogs/Runtime/DialogManager.cs @@ -60,8 +60,13 @@ public async Task CancelAsync(long chatId, CancellationToken ct) return "Ок, отменил. Напиши /help"; } - public async Task ResetAsync(long chatId, CancellationToken ct) + public async Task ResetAsync(long chatId, CancellationToken ct) { + var ctx = await store.GetOrCreateAsync(chatId, ct); + var hadActiveDialog = ctx.HasActiveDialog; + await store.ResetAsync(chatId, ct); + + return hadActiveDialog; } } \ No newline at end of file diff --git a/src/LinkTracker.Bot.Application/Routing/UpdateRouter.cs b/src/LinkTracker.Bot.Application/Routing/UpdateRouter.cs index 2211c95..b75b8bd 100644 --- a/src/LinkTracker.Bot.Application/Routing/UpdateRouter.cs +++ b/src/LinkTracker.Bot.Application/Routing/UpdateRouter.cs @@ -11,14 +11,20 @@ public sealed class UpdateRouter( DialogManager dialogManager, IBotMetrics metrics) { + private const string DialogInterruptedNotice = "Прерван незавершённый диалог, введённые данные не сохранены."; + public async Task RouteAsync(BotRequest request, CancellationToken ct) { metrics.IncrementRequest(request.Type.ToString()); if (IsCommand(request)) { - await dialogManager.ResetAsync(request.ChatId, ct); - return await RouteCommandAsync(request, ct); + var dialogInterrupted = await dialogManager.ResetAsync(request.ChatId, ct); + var message = await RouteCommandAsync(request, ct); + + return dialogInterrupted + ? message with { Text = $"{DialogInterruptedNotice}{Environment.NewLine}{Environment.NewLine}{message.Text}" } + : message; } var (handled, replyText) = await dialogManager.TryHandleAsync(request, ct); @@ -45,4 +51,4 @@ private static string ToCommandText(BotRequest request) { return request.Command is null ? string.Empty : $"/{request.Command}"; } -} \ No newline at end of file +} diff --git a/src/LinkTracker.Bot.Infrastructure/Clients/Registration/ClientsModule.cs b/src/LinkTracker.Bot.Infrastructure/Clients/Registration/ClientsModule.cs index f0453dc..8bd85b2 100644 --- a/src/LinkTracker.Bot.Infrastructure/Clients/Registration/ClientsModule.cs +++ b/src/LinkTracker.Bot.Infrastructure/Clients/Registration/ClientsModule.cs @@ -2,6 +2,7 @@ using Confluent.Kafka; using Confluent.SchemaRegistry; using Confluent.SchemaRegistry.Serdes; +using Grpc.Core.Interceptors; using Grpc.Net.Client; using LinkTracker.Bot.Application.Clients.Scrapper; using LinkTracker.Bot.Infrastructure.Abstractions.Kafka; @@ -13,6 +14,7 @@ using LinkTracker.Bot.Infrastructure.Kafka.Deserialization; using LinkTracker.Grpc; using LinkTracker.Shared.Infrastructure; +using LinkTracker.Shared.Infrastructure.Authentication; using LinkTracker.Shared.Infrastructure.Resilience; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -50,6 +52,7 @@ public static IServiceCollection AddClients( var options = sp.GetRequiredService>().Value; client.BaseAddress = new Uri(options.BaseUrl); }) + .AddHttpMessageHandler() .AddConfiguredHttpResilience("bot-to-scrapper", httpResilienceOptions); services.AddSingleton(sp => @@ -60,8 +63,11 @@ public static IServiceCollection AddClients( services.AddSingleton(sp => { - var channel = sp.GetRequiredService(); - return new ScrapperGrpc.ScrapperGrpcClient(channel); + var invoker = sp.GetRequiredService() + .CreateCallInvoker() + .Intercept(sp.GetRequiredService()); + + return new ScrapperGrpc.ScrapperGrpcClient(invoker); }); services.AddSingleton(); diff --git a/src/LinkTracker.Bot.Infrastructure/LinkTracker.Bot.Infrastructure.csproj b/src/LinkTracker.Bot.Infrastructure/LinkTracker.Bot.Infrastructure.csproj index f616dec..56078a2 100644 --- a/src/LinkTracker.Bot.Infrastructure/LinkTracker.Bot.Infrastructure.csproj +++ b/src/LinkTracker.Bot.Infrastructure/LinkTracker.Bot.Infrastructure.csproj @@ -13,9 +13,6 @@ - - - diff --git a/src/LinkTracker.Bot.Infrastructure/Logging/SerilogConfig.cs b/src/LinkTracker.Bot.Infrastructure/Logging/SerilogConfig.cs deleted file mode 100644 index 797c76c..0000000 --- a/src/LinkTracker.Bot.Infrastructure/Logging/SerilogConfig.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Serilog; -using Serilog.Formatting.Compact; - -namespace LinkTracker.Bot.Infrastructure.Logging; - -public static class SerilogConfig -{ - public static LoggerConfiguration Configure(LoggerConfiguration lc) - { - return lc.Enrich.FromLogContext() - .WriteTo.Console(new CompactJsonFormatter()); - } -} \ No newline at end of file diff --git a/src/LinkTracker.Bot.Presentation/BotApi/Endpoints/LinkUpdateEndpoints.cs b/src/LinkTracker.Bot.Presentation/BotApi/Endpoints/LinkUpdateEndpoints.cs index b109b04..f027fa8 100644 --- a/src/LinkTracker.Bot.Presentation/BotApi/Endpoints/LinkUpdateEndpoints.cs +++ b/src/LinkTracker.Bot.Presentation/BotApi/Endpoints/LinkUpdateEndpoints.cs @@ -10,8 +10,10 @@ namespace LinkTracker.Bot.Presentation.BotApi.Endpoints; public static class LinkUpdateEndpoints { - public static IEndpointRouteBuilder MapBotApi(this IEndpointRouteBuilder app) + public static RouteGroupBuilder MapBotApi(this IEndpointRouteBuilder builder) { + var app = builder.MapGroup(string.Empty); + app.MapPost("/updates", HandleUpdateAsync) .WithName("HandleUpdate") .WithSummary("Отправить обновление") diff --git a/src/LinkTracker.Bot.Presentation/Telegram/Hosting/TelegramPollingHostedService.cs b/src/LinkTracker.Bot.Presentation/Telegram/Hosting/TelegramPollingHostedService.cs index 4684a8f..7e2059a 100644 --- a/src/LinkTracker.Bot.Presentation/Telegram/Hosting/TelegramPollingHostedService.cs +++ b/src/LinkTracker.Bot.Presentation/Telegram/Hosting/TelegramPollingHostedService.cs @@ -17,7 +17,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var receiverOptions = new ReceiverOptions { AllowedUpdates = Array.Empty() }; var me = await bot.GetMe(stoppingToken); - logger.LogInformation("Бот {Username} успешно запущен. Нажми Enter чтобы остановить.", me.Username); + logger.LogInformation("Бот {Username} успешно запущен, начат приём обновлений.", me.Username); try { diff --git a/src/LinkTracker.EnvReader/DotEnvConfigurationExtensions.cs b/src/LinkTracker.EnvReader/DotEnvConfigurationExtensions.cs index 9885281..fd41b94 100644 --- a/src/LinkTracker.EnvReader/DotEnvConfigurationExtensions.cs +++ b/src/LinkTracker.EnvReader/DotEnvConfigurationExtensions.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.EnvironmentVariables; +using Microsoft.Extensions.Configuration.Memory; namespace LinkTracker.EnvReader; @@ -15,6 +17,31 @@ public static IConfigurationBuilder AddDotEnv( return builder; } - return builder.AddInMemoryCollection(pairs); + var source = new MemoryConfigurationSource { InitialData = pairs }; + var environmentVariablesIndex = IndexOfEnvironmentVariables(builder.Sources); + + if (environmentVariablesIndex < 0) + { + builder.Sources.Add(source); + } + else + { + builder.Sources.Insert(environmentVariablesIndex, source); + } + + return builder; + } + + private static int IndexOfEnvironmentVariables(IList sources) + { + for (var index = 0; index < sources.Count; index++) + { + if (sources[index] is EnvironmentVariablesConfigurationSource) + { + return index; + } + } + + return -1; } -} \ No newline at end of file +} diff --git a/src/LinkTracker.EnvReader/LinkTracker.EnvReader.csproj b/src/LinkTracker.EnvReader/LinkTracker.EnvReader.csproj index 27fa1e2..02e15a6 100644 --- a/src/LinkTracker.EnvReader/LinkTracker.EnvReader.csproj +++ b/src/LinkTracker.EnvReader/LinkTracker.EnvReader.csproj @@ -8,6 +8,7 @@ + diff --git a/src/LinkTracker.Scrapper.Api/.env.template b/src/LinkTracker.Scrapper.Api/.env.template index 8bceb14..1dd77e6 100644 --- a/src/LinkTracker.Scrapper.Api/.env.template +++ b/src/LinkTracker.Scrapper.Api/.env.template @@ -1 +1,2 @@ -GitHub__Token=ghp_abc \ No newline at end of file +GitHub__Token=ghp_abc +ServiceAuth__Secret=change-me-to-a-long-random-string diff --git a/src/LinkTracker.Scrapper.Api/Program.cs b/src/LinkTracker.Scrapper.Api/Program.cs index 511fbe2..b59a692 100644 --- a/src/LinkTracker.Scrapper.Api/Program.cs +++ b/src/LinkTracker.Scrapper.Api/Program.cs @@ -13,6 +13,8 @@ using LinkTracker.Scrapper.Infrastructure.Telemetry.Registration; using LinkTracker.Scrapper.Presentation.Endpoints; using LinkTracker.Scrapper.Presentation.Grpc; +using LinkTracker.Shared.Infrastructure.Authentication; +using LinkTracker.Shared.Infrastructure.Logging; using LinkTracker.Shared.Infrastructure.RateLimiting; using LinkTracker.Shared.Infrastructure.Resilience; using LinkTracker.Shared.Infrastructure.Telemetry; @@ -20,13 +22,16 @@ var builder = WebApplication.CreateBuilder(args); builder.AddLocalDotEnv(); +builder.AddSharedSerilog("scrapper"); builder.Services.AddGrpc(options => { options.Interceptors.Add(); }); builder.Services.AddHttpResilienceOptions(builder.Configuration); -builder.Services.AddIpRateLimiting(builder.Configuration); +builder.Services.AddApiRateLimiting(builder.Configuration); +builder.Services.AddServiceAuthentication(builder.Configuration); +builder.Services.AddServiceAuthClients(builder.Configuration); builder.Services.AddScrapperOpenApi(); builder.Services.AddDatabase(builder.Configuration); @@ -47,14 +52,22 @@ } app.UseScrapperExceptionHandling(); -app.UseScrapperOpenApi(); +app.UseScrapperOpenApi(app.Environment); + +app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); app.UseRateLimiter(); app.UseMiddleware(); app.UseMiddleware(); -app.MapScrapperEndpoints(); -app.MapGrpcService(); +app.MapScrapperEndpoints() + .RequireServiceAuthorization() + .RequireRateLimiting(RateLimitingPolicies.PublicApi); + +app.MapGrpcService() + .RequireServiceAuthorization(); app.MapMetricsEndpoint(); -await app.RunAsync(); \ No newline at end of file +await app.RunAsync(); diff --git a/src/LinkTracker.Scrapper.Infrastructure/Clients/Registration/ClientsModule.cs b/src/LinkTracker.Scrapper.Infrastructure/Clients/Registration/ClientsModule.cs index 97f7529..b156823 100644 --- a/src/LinkTracker.Scrapper.Infrastructure/Clients/Registration/ClientsModule.cs +++ b/src/LinkTracker.Scrapper.Infrastructure/Clients/Registration/ClientsModule.cs @@ -3,6 +3,7 @@ using Confluent.Kafka; using Confluent.SchemaRegistry; using Confluent.SchemaRegistry.Serdes; +using Grpc.Core.Interceptors; using Grpc.Net.Client; using LinkTracker.Grpc; using LinkTracker.Scrapper.Application.Clients.GitHub; @@ -16,6 +17,7 @@ using LinkTracker.Scrapper.Infrastructure.Kafka.Abstractions; using LinkTracker.Scrapper.Infrastructure.Kafka.Serialization; using LinkTracker.Shared.Infrastructure; +using LinkTracker.Shared.Infrastructure.Authentication; using LinkTracker.Shared.Infrastructure.Resilience; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -55,6 +57,7 @@ public static IServiceCollection AddClients(this IServiceCollection services, IC var options = sp.GetRequiredService>().Value; client.BaseAddress = new Uri(options.BaseUrl); }) + .AddHttpMessageHandler() .AddConfiguredHttpResilience("scrapper-to-bot", httpResilienceOptions); services.AddSingleton(sp => @@ -65,8 +68,11 @@ public static IServiceCollection AddClients(this IServiceCollection services, IC services.AddSingleton(sp => { - var channel = sp.GetRequiredService(); - return new BotUpdatesGrpc.BotUpdatesGrpcClient(channel); + var invoker = sp.GetRequiredService() + .CreateCallInvoker() + .Intercept(sp.GetRequiredService()); + + return new BotUpdatesGrpc.BotUpdatesGrpcClient(invoker); }); services.AddSingleton(); diff --git a/src/LinkTracker.Scrapper.Infrastructure/Configuration/Database/DatabaseOptions.cs b/src/LinkTracker.Scrapper.Infrastructure/Configuration/Database/DatabaseOptions.cs index c8ca022..7ccd613 100644 --- a/src/LinkTracker.Scrapper.Infrastructure/Configuration/Database/DatabaseOptions.cs +++ b/src/LinkTracker.Scrapper.Infrastructure/Configuration/Database/DatabaseOptions.cs @@ -12,6 +12,8 @@ public sealed class DatabaseOptions public DatabaseAccessType AccessType { get; init; } = DatabaseAccessType.Orm; public string MigrationsPath { get; init; } = "migrations"; public bool RunMigrations { get; init; } = true; + public SslMode SslMode { get; init; } = SslMode.Disable; + public bool TrustServerCertificate { get; init; } public string BuildConnectionString() { @@ -22,7 +24,8 @@ public string BuildConnectionString() Database = Name, Username = User, Password = Password, - SslMode = SslMode.Disable + SslMode = SslMode, + TrustServerCertificate = TrustServerCertificate }; return builder.ConnectionString; diff --git a/src/LinkTracker.Scrapper.Infrastructure/Errors/ApiErrorResponseFactory.cs b/src/LinkTracker.Scrapper.Infrastructure/Errors/ApiErrorResponseFactory.cs index 7edee44..02227fe 100644 --- a/src/LinkTracker.Scrapper.Infrastructure/Errors/ApiErrorResponseFactory.cs +++ b/src/LinkTracker.Scrapper.Infrastructure/Errors/ApiErrorResponseFactory.cs @@ -7,15 +7,21 @@ public static class ApiErrorResponseFactory public static ApiErrorResponse Create( string description, string code, - Exception? exception = null) + Exception? exception = null, + bool includeExceptionDetails = false) { + if (exception is null || !includeExceptionDetails) + { + return new ApiErrorResponse { Description = description, Code = code }; + } + return new ApiErrorResponse { Description = description, Code = code, - ExceptionName = exception?.GetType().Name, - ExceptionMessage = exception?.Message, - Stacktrace = exception?.StackTrace?.Split(Environment.NewLine) ?? Array.Empty() + ExceptionName = exception.GetType().Name, + ExceptionMessage = exception.Message, + Stacktrace = exception.StackTrace?.Split(Environment.NewLine) ?? [] }; } -} \ No newline at end of file +} diff --git a/src/LinkTracker.Scrapper.Infrastructure/Errors/ApiExceptionHandler.cs b/src/LinkTracker.Scrapper.Infrastructure/Errors/ApiExceptionHandler.cs index d8bf9c9..16346b2 100644 --- a/src/LinkTracker.Scrapper.Infrastructure/Errors/ApiExceptionHandler.cs +++ b/src/LinkTracker.Scrapper.Infrastructure/Errors/ApiExceptionHandler.cs @@ -2,14 +2,21 @@ using LinkTracker.Scrapper.Application.Errors; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace LinkTracker.Scrapper.Infrastructure.Errors; public static class ApiExceptionHandler { + private const string LoggerName = "LinkTracker.Scrapper.Api.Errors"; + public static async Task HandleAsync(HttpContext context) { var exception = context.Features.Get()?.Error; + var environment = context.RequestServices.GetRequiredService(); + var includeExceptionDetails = environment.IsDevelopment(); var (statusCode, response) = exception switch { @@ -18,19 +25,33 @@ public static async Task HandleAsync(HttpContext context) ApiErrorResponseFactory.Create( apiException.Description, apiException.Code, - apiException)), + apiException, + includeExceptionDetails)), _ => ( (int)HttpStatusCode.InternalServerError, ApiErrorResponseFactory.Create( "Внутренняя ошибка сервера.", "internal_error", - exception)) + exception, + includeExceptionDetails)) }; + if (exception is not null and not ApiException) + { + context.RequestServices + .GetRequiredService() + .CreateLogger(LoggerName) + .LogError( + exception, + "Необработанная ошибка при обработке запроса {Method} {Path}.", + context.Request.Method, + context.Request.Path); + } + context.Response.StatusCode = statusCode; context.Response.ContentType = "application/json"; await context.Response.WriteAsJsonAsync(response); } -} \ No newline at end of file +} diff --git a/src/LinkTracker.Scrapper.Infrastructure/OpenApi/OpenApiApplicationBuilderExtensions.cs b/src/LinkTracker.Scrapper.Infrastructure/OpenApi/OpenApiApplicationBuilderExtensions.cs index 01d3c67..66ae5ba 100644 --- a/src/LinkTracker.Scrapper.Infrastructure/OpenApi/OpenApiApplicationBuilderExtensions.cs +++ b/src/LinkTracker.Scrapper.Infrastructure/OpenApi/OpenApiApplicationBuilderExtensions.cs @@ -1,11 +1,19 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Hosting; namespace LinkTracker.Scrapper.Infrastructure.OpenApi; public static class OpenApiApplicationBuilderExtensions { - public static IApplicationBuilder UseScrapperOpenApi(this IApplicationBuilder app) + public static IApplicationBuilder UseScrapperOpenApi( + this IApplicationBuilder app, + IHostEnvironment environment) { + if (!environment.IsDevelopment()) + { + return app; + } + app.UseOpenApi(settings => { settings.Path = "/swagger/{documentName}/swagger.json"; @@ -19,4 +27,4 @@ public static IApplicationBuilder UseScrapperOpenApi(this IApplicationBuilder ap return app; } -} \ No newline at end of file +} diff --git a/src/LinkTracker.Scrapper.Infrastructure/Telemetry/Middleware/ApiRequestsMiddleware.cs b/src/LinkTracker.Scrapper.Infrastructure/Telemetry/Middleware/ApiRequestsMiddleware.cs index 7ce5f3f..6db258a 100644 --- a/src/LinkTracker.Scrapper.Infrastructure/Telemetry/Middleware/ApiRequestsMiddleware.cs +++ b/src/LinkTracker.Scrapper.Infrastructure/Telemetry/Middleware/ApiRequestsMiddleware.cs @@ -1,3 +1,4 @@ +using LinkTracker.Shared.Infrastructure.Telemetry; using Microsoft.AspNetCore.Http; namespace LinkTracker.Scrapper.Infrastructure.Telemetry.Middleware; @@ -10,8 +11,8 @@ public async Task InvokeAsync(HttpContext context) { metrics.ApiRequests.Add( 1, - new KeyValuePair("source", context.Request.Path.ToString())); + new KeyValuePair("source", HttpRouteLabel.Resolve(context))); await next(context); } -} \ No newline at end of file +} diff --git a/src/LinkTracker.Scrapper.Infrastructure/Telemetry/Middleware/RequestDurationMiddleware.cs b/src/LinkTracker.Scrapper.Infrastructure/Telemetry/Middleware/RequestDurationMiddleware.cs index 5605ea2..eab67e0 100644 --- a/src/LinkTracker.Scrapper.Infrastructure/Telemetry/Middleware/RequestDurationMiddleware.cs +++ b/src/LinkTracker.Scrapper.Infrastructure/Telemetry/Middleware/RequestDurationMiddleware.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using LinkTracker.Shared.Infrastructure.Telemetry; using Microsoft.AspNetCore.Http; namespace LinkTracker.Scrapper.Infrastructure.Telemetry.Middleware; @@ -10,6 +11,7 @@ public sealed class RequestDurationMiddleware( public async Task InvokeAsync(HttpContext context) { var sw = Stopwatch.StartNew(); + var route = HttpRouteLabel.Resolve(context); try { @@ -20,7 +22,7 @@ public async Task InvokeAsync(HttpContext context) metrics.Errors.Add( 1, new KeyValuePair("scope", "http_api"), - new KeyValuePair("scope_type", context.Request.Path.ToString()), + new KeyValuePair("scope_type", route), new KeyValuePair("reason", "exception")); throw; } @@ -28,21 +30,19 @@ public async Task InvokeAsync(HttpContext context) { sw.Stop(); - var path = context.Request.Path.ToString(); - metrics.RequestDuration.Record( sw.Elapsed.TotalMilliseconds, new KeyValuePair("scope", "http_api"), - new KeyValuePair("scope_type", path)); + new KeyValuePair("scope_type", route)); if (context.Response.StatusCode >= 500) { metrics.Errors.Add( 1, new KeyValuePair("scope", "http_api"), - new KeyValuePair("scope_type", path), + new KeyValuePair("scope_type", route), new KeyValuePair("reason", "5xx")); } } } -} \ No newline at end of file +} diff --git a/src/LinkTracker.Scrapper.Presentation/Endpoints/ScrapperEndpoints.cs b/src/LinkTracker.Scrapper.Presentation/Endpoints/ScrapperEndpoints.cs index 45b8fc0..5492779 100644 --- a/src/LinkTracker.Scrapper.Presentation/Endpoints/ScrapperEndpoints.cs +++ b/src/LinkTracker.Scrapper.Presentation/Endpoints/ScrapperEndpoints.cs @@ -14,8 +14,10 @@ namespace LinkTracker.Scrapper.Presentation.Endpoints; public static class ScrapperEndpoints { - public static IEndpointRouteBuilder MapScrapperEndpoints(this IEndpointRouteBuilder app) + public static RouteGroupBuilder MapScrapperEndpoints(this IEndpointRouteBuilder builder) { + var app = builder.MapGroup(string.Empty); + app.MapPost("/tg-chat/{id:long}", RegisterChat) .WithName("RegisterChat") .WithSummary("Register chat") diff --git a/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthClientInterceptor.cs b/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthClientInterceptor.cs new file mode 100644 index 0000000..b0791f9 --- /dev/null +++ b/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthClientInterceptor.cs @@ -0,0 +1,39 @@ +using Grpc.Core; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.Options; + +namespace LinkTracker.Shared.Infrastructure.Authentication; + +public sealed class ServiceAuthClientInterceptor(IOptions options) : Interceptor +{ + public override AsyncUnaryCall AsyncUnaryCall( + TRequest request, + ClientInterceptorContext context, + AsyncUnaryCallContinuation continuation) + { + return continuation(request, WithServiceToken(context)); + } + + public override TResponse BlockingUnaryCall( + TRequest request, + ClientInterceptorContext context, + BlockingUnaryCallContinuation continuation) + { + return continuation(request, WithServiceToken(context)); + } + + private ClientInterceptorContext WithServiceToken( + ClientInterceptorContext context) + where TRequest : class + where TResponse : class + { + var headers = context.Options.Headers ?? []; + + headers.Add(ServiceAuthDefaults.HeaderName, options.Value.Secret); + + return new ClientInterceptorContext( + context.Method, + context.Host, + context.Options.WithHeaders(headers)); + } +} diff --git a/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthDefaults.cs b/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthDefaults.cs new file mode 100644 index 0000000..dbed33a --- /dev/null +++ b/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthDefaults.cs @@ -0,0 +1,10 @@ +namespace LinkTracker.Shared.Infrastructure.Authentication; + +public static class ServiceAuthDefaults +{ + public const string AuthenticationScheme = "ServiceToken"; + + public const string PolicyName = "service-auth"; + + public const string HeaderName = "x-service-token"; +} diff --git a/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthExtensions.cs b/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthExtensions.cs new file mode 100644 index 0000000..aaeb3d1 --- /dev/null +++ b/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthExtensions.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace LinkTracker.Shared.Infrastructure.Authentication; + +public static class ServiceAuthExtensions +{ + public static IServiceCollection AddServiceAuthentication( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddServiceAuthOptions(configuration); + + services + .AddAuthentication(ServiceAuthDefaults.AuthenticationScheme) + .AddScheme( + ServiceAuthDefaults.AuthenticationScheme, + configureOptions: null); + + services + .AddAuthorizationBuilder() + .AddPolicy( + ServiceAuthDefaults.PolicyName, + policy => policy + .AddAuthenticationSchemes(ServiceAuthDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + return services; + } + + public static IServiceCollection AddServiceAuthClients( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddServiceAuthOptions(configuration); + + services.AddTransient(); + services.AddSingleton(); + + return services; + } + + public static TBuilder RequireServiceAuthorization(this TBuilder builder) + where TBuilder : IEndpointConventionBuilder + { + builder.RequireAuthorization(ServiceAuthDefaults.PolicyName); + + return builder; + } + + private static IServiceCollection AddServiceAuthOptions( + this IServiceCollection services, + IConfiguration configuration) + { + services + .AddOptions() + .Bind(configuration.GetSection(ServiceAuthOptions.SectionName)) + .Validate( + o => !string.IsNullOrWhiteSpace(o.Secret), + $"{ServiceAuthOptions.SectionName}:Secret must be set") + .ValidateOnStart(); + + return services; + } +} diff --git a/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthHeaderHandler.cs b/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthHeaderHandler.cs new file mode 100644 index 0000000..6d67815 --- /dev/null +++ b/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthHeaderHandler.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Options; + +namespace LinkTracker.Shared.Infrastructure.Authentication; + +public sealed class ServiceAuthHeaderHandler(IOptions options) : DelegatingHandler +{ + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + request.Headers.Remove(ServiceAuthDefaults.HeaderName); + request.Headers.Add(ServiceAuthDefaults.HeaderName, options.Value.Secret); + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthOptions.cs b/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthOptions.cs new file mode 100644 index 0000000..69c8291 --- /dev/null +++ b/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceAuthOptions.cs @@ -0,0 +1,8 @@ +namespace LinkTracker.Shared.Infrastructure.Authentication; + +public sealed class ServiceAuthOptions +{ + public const string SectionName = "ServiceAuth"; + + public string Secret { get; set; } = string.Empty; +} diff --git a/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceTokenAuthenticationHandler.cs b/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceTokenAuthenticationHandler.cs new file mode 100644 index 0000000..a586f09 --- /dev/null +++ b/src/LinkTracker.Shared/Infrastructure/Authentication/ServiceTokenAuthenticationHandler.cs @@ -0,0 +1,55 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace LinkTracker.Shared.Infrastructure.Authentication; + +public sealed class ServiceTokenAuthenticationHandler : AuthenticationHandler +{ + private const string CallerName = "internal-service"; + + private readonly byte[] _secret; + + public ServiceTokenAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory loggerFactory, + UrlEncoder encoder, + IOptions serviceAuthOptions) + : base(options, loggerFactory, encoder) + { + _secret = Encoding.UTF8.GetBytes(serviceAuthOptions.Value.Secret); + } + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(ServiceAuthDefaults.HeaderName, out var values)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + if (!IsKnownSecret(values.ToString())) + { + return Task.FromResult(AuthenticateResult.Fail("Передан неизвестный сервисный токен.")); + } + + var identity = new ClaimsIdentity( + [new Claim(ClaimTypes.Name, CallerName)], + ServiceAuthDefaults.AuthenticationScheme); + + var ticket = new AuthenticationTicket( + new ClaimsPrincipal(identity), + ServiceAuthDefaults.AuthenticationScheme); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + + private bool IsKnownSecret(string token) + { + return _secret.Length > 0 + && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(token), _secret); + } +} diff --git a/src/LinkTracker.Shared/Infrastructure/Logging/SerilogConfig.cs b/src/LinkTracker.Shared/Infrastructure/Logging/SerilogConfig.cs new file mode 100644 index 0000000..916d6da --- /dev/null +++ b/src/LinkTracker.Shared/Infrastructure/Logging/SerilogConfig.cs @@ -0,0 +1,18 @@ +using Serilog; +using Serilog.Events; +using Serilog.Formatting.Compact; + +namespace LinkTracker.Shared.Infrastructure.Logging; + +public static class SerilogConfig +{ + public static LoggerConfiguration Configure(LoggerConfiguration configuration, string serviceName) + { + return configuration + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) + .Enrich.FromLogContext() + .Enrich.WithProperty("service", serviceName) + .WriteTo.Console(new CompactJsonFormatter()); + } +} diff --git a/src/LinkTracker.Shared/Infrastructure/Logging/SerilogHostExtensions.cs b/src/LinkTracker.Shared/Infrastructure/Logging/SerilogHostExtensions.cs new file mode 100644 index 0000000..0359ef8 --- /dev/null +++ b/src/LinkTracker.Shared/Infrastructure/Logging/SerilogHostExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; + +namespace LinkTracker.Shared.Infrastructure.Logging; + +public static class SerilogHostExtensions +{ + public static IHostApplicationBuilder AddSharedSerilog( + this IHostApplicationBuilder builder, + string serviceName) + { + builder.Services.AddSerilog(configuration => SerilogConfig.Configure(configuration, serviceName)); + + return builder; + } +} diff --git a/src/LinkTracker.Shared/Infrastructure/RateLimiting/IpRateLimitingOptions.cs b/src/LinkTracker.Shared/Infrastructure/RateLimiting/ApiRateLimitingOptions.cs similarity index 54% rename from src/LinkTracker.Shared/Infrastructure/RateLimiting/IpRateLimitingOptions.cs rename to src/LinkTracker.Shared/Infrastructure/RateLimiting/ApiRateLimitingOptions.cs index ba727aa..0667871 100644 --- a/src/LinkTracker.Shared/Infrastructure/RateLimiting/IpRateLimitingOptions.cs +++ b/src/LinkTracker.Shared/Infrastructure/RateLimiting/ApiRateLimitingOptions.cs @@ -1,6 +1,6 @@ namespace LinkTracker.Shared.Infrastructure.RateLimiting; -public sealed class IpRateLimitingOptions +public sealed class ApiRateLimitingOptions { public const string SectionName = "RateLimiting"; @@ -10,5 +10,9 @@ public sealed class IpRateLimitingOptions public int SegmentsPerWindow { get; set; } = 6; - public int QueueLimit { get; set; } = 0; -} \ No newline at end of file + public int QueueLimit { get; set; } + + public string PartitionHeaderName { get; set; } = "Tg-Chat-Id"; + + public IReadOnlyList TrustedNetworks { get; set; } = []; +} diff --git a/src/LinkTracker.Shared/Infrastructure/RateLimiting/ApiRateLimitingServiceCollectionExtensions.cs b/src/LinkTracker.Shared/Infrastructure/RateLimiting/ApiRateLimitingServiceCollectionExtensions.cs new file mode 100644 index 0000000..2a8666d --- /dev/null +++ b/src/LinkTracker.Shared/Infrastructure/RateLimiting/ApiRateLimitingServiceCollectionExtensions.cs @@ -0,0 +1,60 @@ +using System.Net; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace LinkTracker.Shared.Infrastructure.RateLimiting; + +public static class ApiRateLimitingServiceCollectionExtensions +{ + public static IServiceCollection AddApiRateLimiting( + this IServiceCollection services, + IConfiguration configuration) + { + var options = configuration + .GetSection(ApiRateLimitingOptions.SectionName) + .Get() ?? new ApiRateLimitingOptions(); + + services + .AddOptions() + .Bind(configuration.GetSection(ApiRateLimitingOptions.SectionName)) + .Validate(o => o.PermitLimit > 0, "RateLimiting:PermitLimit must be positive") + .Validate(o => o.WindowSeconds > 0, "RateLimiting:WindowSeconds must be positive") + .Validate(o => o.SegmentsPerWindow > 0, "RateLimiting:SegmentsPerWindow must be positive") + .Validate(o => o.QueueLimit >= 0, "RateLimiting:QueueLimit must not be negative") + .Validate( + o => !string.IsNullOrWhiteSpace(o.PartitionHeaderName), + "RateLimiting:PartitionHeaderName must be set") + .Validate( + o => o.TrustedNetworks.All(network => IPNetwork.TryParse(network, out _)), + "RateLimiting:TrustedNetworks must contain CIDR notation values") + .ValidateOnStart(); + + var partitionKeyResolver = new RateLimitPartitionKeyResolver(options); + + services.AddRateLimiter(rateLimiterOptions => + { + rateLimiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + rateLimiterOptions.AddPolicy( + RateLimitingPolicies.PublicApi, + context => partitionKeyResolver.IsTrusted(context) + ? RateLimitPartition.GetNoLimiter(RateLimitPartitionKeyResolver.TrustedPartitionKey) + : RateLimitPartition.GetSlidingWindowLimiter( + partitionKeyResolver.Resolve(context), + _ => new SlidingWindowRateLimiterOptions + { + PermitLimit = options.PermitLimit, + Window = TimeSpan.FromSeconds(options.WindowSeconds), + SegmentsPerWindow = options.SegmentsPerWindow, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = options.QueueLimit + })); + }); + + return services; + } +} diff --git a/src/LinkTracker.Shared/Infrastructure/RateLimiting/IpRateLimitingServiceCollectionExtensions.cs b/src/LinkTracker.Shared/Infrastructure/RateLimiting/IpRateLimitingServiceCollectionExtensions.cs deleted file mode 100644 index d327bf0..0000000 --- a/src/LinkTracker.Shared/Infrastructure/RateLimiting/IpRateLimitingServiceCollectionExtensions.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Threading.RateLimiting; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace LinkTracker.Shared.Infrastructure.RateLimiting; - -public static class IpRateLimitingServiceCollectionExtensions -{ - public static IServiceCollection AddIpRateLimiting( - this IServiceCollection services, - IConfiguration configuration) - { - var options = configuration - .GetSection(IpRateLimitingOptions.SectionName) - .Get() ?? new IpRateLimitingOptions(); - - services - .AddOptions() - .Bind(configuration.GetSection(IpRateLimitingOptions.SectionName)) - .Validate(o => o.PermitLimit > 0, "RateLimiting:PermitLimit must be positive") - .Validate(o => o.WindowSeconds > 0, "RateLimiting:WindowSeconds must be positive") - .Validate(o => o.SegmentsPerWindow > 0, "RateLimiting:SegmentsPerWindow must be positive") - .Validate(o => o.QueueLimit >= 0, "RateLimiting:QueueLimit must not be negative") - .ValidateOnStart(); - - services.AddRateLimiter(rateLimiterOptions => - { - rateLimiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - - rateLimiterOptions.GlobalLimiter = PartitionedRateLimiter.Create(context => - { - var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - - return RateLimitPartition.GetSlidingWindowLimiter( - ipAddress, - _ => new SlidingWindowRateLimiterOptions - { - PermitLimit = options.PermitLimit, - Window = TimeSpan.FromSeconds(options.WindowSeconds), - SegmentsPerWindow = options.SegmentsPerWindow, - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = options.QueueLimit - }); - }); - }); - - return services; - } -} \ No newline at end of file diff --git a/src/LinkTracker.Shared/Infrastructure/RateLimiting/RateLimitPartitionKeyResolver.cs b/src/LinkTracker.Shared/Infrastructure/RateLimiting/RateLimitPartitionKeyResolver.cs new file mode 100644 index 0000000..50aa0db --- /dev/null +++ b/src/LinkTracker.Shared/Infrastructure/RateLimiting/RateLimitPartitionKeyResolver.cs @@ -0,0 +1,51 @@ +using System.Net; +using Microsoft.AspNetCore.Http; + +namespace LinkTracker.Shared.Infrastructure.RateLimiting; + +internal sealed class RateLimitPartitionKeyResolver +{ + internal const string TrustedPartitionKey = "trusted"; + + private const string UnknownAddress = "unknown"; + + private readonly string _partitionHeaderName; + private readonly IPNetwork[] _trustedNetworks; + + public RateLimitPartitionKeyResolver(ApiRateLimitingOptions options) + { + _partitionHeaderName = options.PartitionHeaderName; + _trustedNetworks = [.. options.TrustedNetworks.Select(Parse)]; + } + + public bool IsTrusted(HttpContext context) + { + var address = Normalize(context.Connection.RemoteIpAddress); + + return address is not null && Array.Exists(_trustedNetworks, network => network.Contains(address)); + } + + public string Resolve(HttpContext context) + { + if (context.Request.Headers.TryGetValue(_partitionHeaderName, out var values) + && !string.IsNullOrWhiteSpace(values.ToString())) + { + return $"caller:{values.ToString().Trim()}"; + } + + return $"ip:{Normalize(context.Connection.RemoteIpAddress)?.ToString() ?? UnknownAddress}"; + } + + private static IPAddress? Normalize(IPAddress? address) + { + return address?.IsIPv4MappedToIPv6 == true ? address.MapToIPv4() : address; + } + + private static IPNetwork Parse(string network) + { + return IPNetwork.TryParse(network, out var parsed) + ? parsed + : throw new InvalidOperationException( + $"{ApiRateLimitingOptions.SectionName}:TrustedNetworks contains a value that is not CIDR notation: '{network}'."); + } +} diff --git a/src/LinkTracker.Shared/Infrastructure/RateLimiting/RateLimitingPolicies.cs b/src/LinkTracker.Shared/Infrastructure/RateLimiting/RateLimitingPolicies.cs new file mode 100644 index 0000000..b300fb0 --- /dev/null +++ b/src/LinkTracker.Shared/Infrastructure/RateLimiting/RateLimitingPolicies.cs @@ -0,0 +1,6 @@ +namespace LinkTracker.Shared.Infrastructure.RateLimiting; + +public static class RateLimitingPolicies +{ + public const string PublicApi = "public-api"; +} diff --git a/src/LinkTracker.Shared/Infrastructure/Telemetry/HttpRouteLabel.cs b/src/LinkTracker.Shared/Infrastructure/Telemetry/HttpRouteLabel.cs new file mode 100644 index 0000000..1feda86 --- /dev/null +++ b/src/LinkTracker.Shared/Infrastructure/Telemetry/HttpRouteLabel.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace LinkTracker.Shared.Infrastructure.Telemetry; + +public static class HttpRouteLabel +{ + public const string Unmatched = "unmatched"; + + public static string Resolve(HttpContext context) + { + if (context.GetEndpoint() is RouteEndpoint { RoutePattern.RawText: { Length: > 0 } rawText }) + { + return rawText.StartsWith('/') ? rawText : $"/{rawText}"; + } + + return Unmatched; + } +} diff --git a/src/LinkTracker.Shared/LinkTracker.Shared.csproj b/src/LinkTracker.Shared/LinkTracker.Shared.csproj index 8cb9e5e..7405786 100644 --- a/src/LinkTracker.Shared/LinkTracker.Shared.csproj +++ b/src/LinkTracker.Shared/LinkTracker.Shared.csproj @@ -22,6 +22,9 @@ + + + diff --git a/src/LinkTracker.Tests/Bot/Unit/Application/Routing/UpdateRouterTest.cs b/src/LinkTracker.Tests/Bot/Unit/Application/Routing/UpdateRouterTest.cs index c4e817a..c07c6f1 100644 --- a/src/LinkTracker.Tests/Bot/Unit/Application/Routing/UpdateRouterTest.cs +++ b/src/LinkTracker.Tests/Bot/Unit/Application/Routing/UpdateRouterTest.cs @@ -13,6 +13,8 @@ namespace LinkTracker.Tests.Bot.Unit.Application.Routing; [Trait("Category", "Unit")] public sealed class UpdateRouterTests { + private const string DialogInterruptedNotice = "Прерван незавершённый диалог, введённые данные не сохранены."; + [Fact] public async Task Route_WhenCommandAndDialogActive_RoutesToCommandFirst() { @@ -48,7 +50,8 @@ public async Task Route_WhenCommandAndDialogActive_RoutesToCommandFirst() var result = await sut.RouteAsync(request, CancellationToken.None); - Assert.Equal("command handled", result.Text); + Assert.StartsWith(DialogInterruptedNotice, result.Text, StringComparison.Ordinal); + Assert.EndsWith("command handled", result.Text, StringComparison.Ordinal); await command.Received(1).ExecuteAsync(chatId, "/help", Arg.Any()); await dialogNode.DidNotReceive() @@ -124,7 +127,8 @@ public async Task Route_WhenCommandReceivedAndDialogActive_RoutesThroughCommandR var result = await sut.RouteAsync(request, CancellationToken.None); - Assert.Equal("command handled", result.Text); + Assert.StartsWith(DialogInterruptedNotice, result.Text, StringComparison.Ordinal); + Assert.EndsWith("command handled", result.Text, StringComparison.Ordinal); await command.Received(1) .ExecuteAsync(chatId, "/track", Arg.Any()); diff --git a/src/LinkTracker.Tests/Shared/Integration/Infrastructure/Authentication/GrpcServiceAuthTests.cs b/src/LinkTracker.Tests/Shared/Integration/Infrastructure/Authentication/GrpcServiceAuthTests.cs new file mode 100644 index 0000000..06a8878 --- /dev/null +++ b/src/LinkTracker.Tests/Shared/Integration/Infrastructure/Authentication/GrpcServiceAuthTests.cs @@ -0,0 +1,118 @@ +using Grpc.Core; +using Grpc.Core.Interceptors; +using Grpc.Net.Client; +using LinkTracker.Bot.Application.Updates.Abstractions; +using LinkTracker.Bot.Presentation.Grpc; +using LinkTracker.Grpc; +using LinkTracker.Shared.Contracts.Bot; +using LinkTracker.Shared.Infrastructure.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace LinkTracker.Tests.Shared.Integration.Infrastructure.Authentication; + +[Trait("Module", "Shared")] +[Trait("Category", "Integration")] +public sealed class GrpcServiceAuthTests +{ + private const string Secret = "9f2e4c17-grpc-service-secret"; + + [Fact] + public async Task SendUpdate_WhenClientAttachesServiceToken_IsHandled() + { + var notifier = Substitute.For(); + + using var server = CreateServer(notifier); + var client = CreateClient(server, Secret); + + await client.SendUpdateAsync(CreateRequest()); + + await notifier.Received(1).NotifyAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task SendUpdate_WhenServiceTokenIsMissing_IsRejected() + { + var notifier = Substitute.For(); + + using var server = CreateServer(notifier); + var client = CreateClient(server, token: null); + + var exception = await Assert.ThrowsAsync( + async () => await client.SendUpdateAsync(CreateRequest())); + + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + + await notifier.DidNotReceive().NotifyAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task SendUpdate_WhenServiceTokenIsWrong_IsRejected() + { + var notifier = Substitute.For(); + + using var server = CreateServer(notifier); + var client = CreateClient(server, "not-the-secret"); + + var exception = await Assert.ThrowsAsync( + async () => await client.SendUpdateAsync(CreateRequest())); + + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + + await notifier.DidNotReceive().NotifyAsync(Arg.Any(), Arg.Any()); + } + + private static TestServer CreateServer(ILinkUpdateNotifier notifier) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["ServiceAuth:Secret"] = Secret }) + .Build(); + + return new TestServer(new WebHostBuilder() + .ConfigureServices(services => + { + services.AddRouting(); + services.AddGrpc(); + services.AddSingleton(notifier); + services.AddServiceAuthentication(configuration); + }) + .Configure(app => + { + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseEndpoints(endpoints => + endpoints.MapGrpcService().RequireServiceAuthorization()); + })); + } + + private static BotUpdatesGrpc.BotUpdatesGrpcClient CreateClient(TestServer server, string? token) + { + var channel = GrpcChannel.ForAddress( + server.BaseAddress, + new GrpcChannelOptions { HttpHandler = server.CreateHandler() }); + + if (token is null) + { + return new BotUpdatesGrpc.BotUpdatesGrpcClient(channel); + } + + var interceptor = new ServiceAuthClientInterceptor(Options.Create(new ServiceAuthOptions { Secret = token })); + + return new BotUpdatesGrpc.BotUpdatesGrpcClient(channel.CreateCallInvoker().Intercept(interceptor)); + } + + private static LinkUpdateGrpcRequest CreateRequest() + { + var request = new LinkUpdateGrpcRequest { Id = 1, Url = "https://github.com/user/repo", Description = "update" }; + + request.TgChatIds.Add(42); + + return request; + } +} diff --git a/src/LinkTracker.Tests/Shared/Integration/Infrastructure/Authentication/ServiceAuthTests.cs b/src/LinkTracker.Tests/Shared/Integration/Infrastructure/Authentication/ServiceAuthTests.cs new file mode 100644 index 0000000..8296f59 --- /dev/null +++ b/src/LinkTracker.Tests/Shared/Integration/Infrastructure/Authentication/ServiceAuthTests.cs @@ -0,0 +1,103 @@ +using System.Net; +using LinkTracker.Shared.Infrastructure.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace LinkTracker.Tests.Shared.Integration.Infrastructure.Authentication; + +[Trait("Module", "Shared")] +[Trait("Category", "Integration")] +public sealed class ServiceAuthTests +{ + private const string Secret = "b8a1c0d5-service-secret"; + private const string ProtectedPath = "/links"; + private const string PublicPath = "/metrics"; + + [Fact] + public async Task Get_WhenServiceTokenIsValid_AllowsRequest() + { + using var server = CreateServer(); + using var client = server.CreateClient(); + + using var response = await SendAsync(client, ProtectedPath, Secret); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Get_WhenServiceTokenIsMissing_RejectsRequest() + { + using var server = CreateServer(); + using var client = server.CreateClient(); + + using var response = await SendAsync(client, ProtectedPath, token: null); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Get_WhenServiceTokenIsWrong_RejectsRequest() + { + using var server = CreateServer(); + using var client = server.CreateClient(); + + using var response = await SendAsync(client, ProtectedPath, "not-the-secret"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Get_WhenEndpointIsNotProtected_AllowsAnonymousRequest() + { + using var server = CreateServer(); + using var client = server.CreateClient(); + + using var response = await SendAsync(client, PublicPath, token: null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private static TestServer CreateServer() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["ServiceAuth:Secret"] = Secret }) + .Build(); + + return new TestServer(new WebHostBuilder() + .ConfigureServices(services => + { + services.AddRouting(); + services.AddServiceAuthentication(configuration); + }) + .Configure(app => + { + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseEndpoints(endpoints => + { + endpoints + .MapGet(ProtectedPath, () => Results.Ok()) + .RequireServiceAuthorization(); + + endpoints.MapGet(PublicPath, () => Results.Ok()); + }); + })); + } + + private static async Task SendAsync(HttpClient client, string path, string? token) + { + using var request = new HttpRequestMessage(HttpMethod.Get, path); + + if (token is not null) + { + request.Headers.Add(ServiceAuthDefaults.HeaderName, token); + } + + return await client.SendAsync(request); + } +} diff --git a/src/LinkTracker.Tests/Shared/Integration/Infrastructure/RateLimiting/ApiRateLimitingTests.cs b/src/LinkTracker.Tests/Shared/Integration/Infrastructure/RateLimiting/ApiRateLimitingTests.cs new file mode 100644 index 0000000..6dccde0 --- /dev/null +++ b/src/LinkTracker.Tests/Shared/Integration/Infrastructure/RateLimiting/ApiRateLimitingTests.cs @@ -0,0 +1,168 @@ +using System.Net; +using LinkTracker.Shared.Infrastructure.RateLimiting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace LinkTracker.Tests.Shared.Integration.Infrastructure.RateLimiting; + +[Trait("Module", "Shared")] +[Trait("Category", "Integration")] +public sealed class ApiRateLimitingTests +{ + private const string RemoteIpAddressHeaderName = "X-Test-Remote-Ip"; + private const string ChatIdHeaderName = "Tg-Chat-Id"; + private const string LimitedPath = "/links"; + private const string UnlimitedPath = "/metrics"; + private const string FirstIpAddress = "127.0.0.1"; + private const string SecondIpAddress = "127.0.0.2"; + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public async Task Get_WhenPermitLimitConfigured_AllowsRequestsUntilLimit(int permitLimit) + { + using var server = CreateServer(settings => + { + settings["RateLimiting:PermitLimit"] = permitLimit.ToString(); + }); + + using var client = server.CreateClient(); + + for (var i = 0; i < permitLimit; i++) + { + using var allowed = await SendAsync(client, LimitedPath, FirstIpAddress, chatId: "100"); + + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + } + + using var rejected = await SendAsync(client, LimitedPath, FirstIpAddress, chatId: "100"); + + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + } + + [Fact] + public async Task Get_WhenChatIdsDifferButIpIsShared_UsesIndependentLimits() + { + using var server = CreateServer(); + using var client = server.CreateClient(); + + using var firstChatFirstCall = await SendAsync(client, LimitedPath, FirstIpAddress, chatId: "100"); + using var firstChatSecondCall = await SendAsync(client, LimitedPath, FirstIpAddress, chatId: "100"); + using var secondChatFirstCall = await SendAsync(client, LimitedPath, FirstIpAddress, chatId: "200"); + + Assert.Equal(HttpStatusCode.OK, firstChatFirstCall.StatusCode); + Assert.Equal(HttpStatusCode.TooManyRequests, firstChatSecondCall.StatusCode); + Assert.Equal(HttpStatusCode.OK, secondChatFirstCall.StatusCode); + } + + [Fact] + public async Task Get_WhenChatIdHeaderIsMissing_FallsBackToRemoteIpPartition() + { + using var server = CreateServer(); + using var client = server.CreateClient(); + + using var firstIpFirstCall = await SendAsync(client, LimitedPath, FirstIpAddress); + using var firstIpSecondCall = await SendAsync(client, LimitedPath, FirstIpAddress); + using var secondIpFirstCall = await SendAsync(client, LimitedPath, SecondIpAddress); + + Assert.Equal(HttpStatusCode.OK, firstIpFirstCall.StatusCode); + Assert.Equal(HttpStatusCode.TooManyRequests, firstIpSecondCall.StatusCode); + Assert.Equal(HttpStatusCode.OK, secondIpFirstCall.StatusCode); + } + + [Fact] + public async Task Get_WhenRemoteIpIsTrusted_IsNotThrottled() + { + using var server = CreateServer(settings => + { + settings["RateLimiting:TrustedNetworks:0"] = "127.0.0.0/8"; + }); + + using var client = server.CreateClient(); + + using var first = await SendAsync(client, LimitedPath, FirstIpAddress, chatId: "100"); + using var second = await SendAsync(client, LimitedPath, FirstIpAddress, chatId: "100"); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + } + + [Fact] + public async Task Get_WhenEndpointDoesNotRequirePolicy_IsNotThrottled() + { + using var server = CreateServer(); + using var client = server.CreateClient(); + + using var first = await SendAsync(client, UnlimitedPath, FirstIpAddress); + using var second = await SendAsync(client, UnlimitedPath, FirstIpAddress); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + } + + private static TestServer CreateServer(Action>? configureSettings = null) + { + var settings = new Dictionary { ["RateLimiting:PermitLimit"] = "1", ["RateLimiting:WindowSeconds"] = "60", ["RateLimiting:SegmentsPerWindow"] = "1", ["RateLimiting:QueueLimit"] = "0" }; + + configureSettings?.Invoke(settings); + + return new TestServer(new WebHostBuilder() + .ConfigureServices(services => + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(settings) + .Build(); + + services.AddRouting(); + services.AddApiRateLimiting(configuration); + }) + .Configure(app => + { + app.Use(SetRemoteIpAddressFromHeader); + app.UseRouting(); + app.UseRateLimiter(); + app.UseEndpoints(endpoints => + { + endpoints + .MapGet(LimitedPath, () => Results.Ok()) + .RequireRateLimiting(RateLimitingPolicies.PublicApi); + + endpoints.MapGet(UnlimitedPath, () => Results.Ok()); + }); + })); + } + + private static Task SetRemoteIpAddressFromHeader(HttpContext context, RequestDelegate next) + { + if (context.Request.Headers.TryGetValue(RemoteIpAddressHeaderName, out var values) + && IPAddress.TryParse(values.ToString(), out var ipAddress)) + { + context.Connection.RemoteIpAddress = ipAddress; + } + + return next(context); + } + + private static async Task SendAsync( + HttpClient client, + string path, + string remoteIpAddress, + string? chatId = null) + { + using var request = new HttpRequestMessage(HttpMethod.Get, path); + + request.Headers.Add(RemoteIpAddressHeaderName, remoteIpAddress); + + if (chatId is not null) + { + request.Headers.Add(ChatIdHeaderName, chatId); + } + + return await client.SendAsync(request); + } +} diff --git a/src/LinkTracker.Tests/Shared/Integration/Infrastructure/RateLimiting/IpRateLimitingTests.cs b/src/LinkTracker.Tests/Shared/Integration/Infrastructure/RateLimiting/IpRateLimitingTests.cs deleted file mode 100644 index 8efc5ed..0000000 --- a/src/LinkTracker.Tests/Shared/Integration/Infrastructure/RateLimiting/IpRateLimitingTests.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System.Net; -using LinkTracker.Shared.Infrastructure.RateLimiting; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; - -namespace LinkTracker.Tests.Shared.Integration.Infrastructure.RateLimiting; - -[Trait("Module", "Shared")] -[Trait("Category", "Integration")] -public sealed class IpRateLimitingTests -{ - private const string RemoteIpAddressHeaderName = "X-Test-Remote-Ip"; - private const string FirstIpAddress = "127.0.0.1"; - private const string SecondIpAddress = "127.0.0.2"; - private const string MissingRemoteIpAddressHeaderValue = "none"; - - [Theory] - [InlineData(1)] - [InlineData(2)] - [InlineData(3)] - public async Task GetAsync_WhenPermitLimitConfigured_AllowsRequestsUntilLimit(int permitLimit) - { - using var server = CreateServer(settings => - { - settings["RateLimiting:PermitLimit"] = permitLimit.ToString(); - }); - using var client = server.CreateClient(); - - var allowedResponses = new List(); - - try - { - for (var i = 0; i < permitLimit; i++) - { - allowedResponses.Add(await GetAsync(client, FirstIpAddress)); - } - - using var rejectedResponse = await GetAsync(client, FirstIpAddress); - - Assert.All(allowedResponses, response => - Assert.Equal(HttpStatusCode.OK, response.StatusCode)); - - Assert.Equal(HttpStatusCode.TooManyRequests, rejectedResponse.StatusCode); - } - finally - { - foreach (var response in allowedResponses) - { - response.Dispose(); - } - } - } - - [Fact] - public async Task GetAsync_WhenRequestsComeFromDifferentIps_UsesIndependentLimits() - { - using var server = CreateServer(); - using var client = server.CreateClient(); - - using var firstIpFirstResponse = await GetAsync(client, FirstIpAddress); - using var firstIpSecondResponse = await GetAsync(client, FirstIpAddress); - using var secondIpFirstResponse = await GetAsync(client, SecondIpAddress); - - Assert.Equal(HttpStatusCode.OK, firstIpFirstResponse.StatusCode); - Assert.Equal(HttpStatusCode.TooManyRequests, firstIpSecondResponse.StatusCode); - Assert.Equal(HttpStatusCode.OK, secondIpFirstResponse.StatusCode); - } - - [Fact] - public async Task GetAsync_WhenRemoteIpAddressIsMissing_UsesSharedUnknownPartition() - { - using var server = CreateServer(); - using var client = server.CreateClient(); - - using var firstResponse = await GetWithoutRemoteIpAddressAsync(client); - using var secondResponse = await GetWithoutRemoteIpAddressAsync(client); - - Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); - Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode); - } - - private static TestServer CreateServer(Action>? configureSettings = null) - { - var settings = new Dictionary { ["RateLimiting:PermitLimit"] = "1", ["RateLimiting:WindowSeconds"] = "60", ["RateLimiting:SegmentsPerWindow"] = "1", ["RateLimiting:QueueLimit"] = "0" }; - - configureSettings?.Invoke(settings); - - return new TestServer(new WebHostBuilder() - .ConfigureServices(services => - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(settings) - .Build(); - - services.AddIpRateLimiting(configuration); - }) - .Configure(app => - { - app.Use(SetRemoteIpAddressFromHeader); - app.UseRateLimiter(); - app.Run(context => context.Response.WriteAsync("ok")); - })); - } - - private static Task SetRemoteIpAddressFromHeader(HttpContext context, RequestDelegate next) - { - if (context.Request.Headers.TryGetValue(RemoteIpAddressHeaderName, out var values)) - { - var rawIpAddress = values.ToString(); - - if (rawIpAddress == MissingRemoteIpAddressHeaderValue) - { - context.Connection.RemoteIpAddress = null; - } - else if (IPAddress.TryParse(rawIpAddress, out var ipAddress)) - { - context.Connection.RemoteIpAddress = ipAddress; - } - } - - return next(context); - } - - private static Task GetAsync(HttpClient client, string ipAddress) - { - return GetWithRemoteIpAddressHeaderAsync(client, ipAddress); - } - - private static Task GetWithoutRemoteIpAddressAsync(HttpClient client) - { - return GetWithRemoteIpAddressHeaderAsync(client, MissingRemoteIpAddressHeaderValue); - } - - private static async Task GetWithRemoteIpAddressHeaderAsync( - HttpClient client, - string remoteIpAddressHeaderValue) - { - using var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Add(RemoteIpAddressHeaderName, remoteIpAddressHeaderValue); - - return await client.SendAsync(request); - } -} \ No newline at end of file diff --git a/src/LinkTracker.Tests/Shared/Integration/Infrastructure/Telemetry/HttpRouteLabelTests.cs b/src/LinkTracker.Tests/Shared/Integration/Infrastructure/Telemetry/HttpRouteLabelTests.cs new file mode 100644 index 0000000..a36a6d7 --- /dev/null +++ b/src/LinkTracker.Tests/Shared/Integration/Infrastructure/Telemetry/HttpRouteLabelTests.cs @@ -0,0 +1,59 @@ +using LinkTracker.Shared.Infrastructure.Telemetry; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace LinkTracker.Tests.Shared.Integration.Infrastructure.Telemetry; + +[Trait("Module", "Shared")] +[Trait("Category", "Integration")] +public sealed class HttpRouteLabelTests +{ + [Fact] + public async Task Resolve_WhenRouteHasParameters_ReturnsRouteTemplateInsteadOfRawPath() + { + var labels = new List(); + + using var server = CreateServer(labels); + using var client = server.CreateClient(); + + using var first = await client.PostAsync("/tg-chat/1", content: null); + using var second = await client.PostAsync("/tg-chat/2", content: null); + + first.EnsureSuccessStatusCode(); + second.EnsureSuccessStatusCode(); + + Assert.Equal(["/tg-chat/{id:long}", "/tg-chat/{id:long}"], labels); + } + + [Fact] + public async Task Resolve_WhenRequestMatchesNoEndpoint_ReturnsUnmatchedLabel() + { + var labels = new List(); + + using var server = CreateServer(labels); + using var client = server.CreateClient(); + + using var response = await client.GetAsync("/does-not-exist"); + + Assert.Equal([HttpRouteLabel.Unmatched], labels); + } + + private static TestServer CreateServer(List labels) + { + return new TestServer(new WebHostBuilder() + .ConfigureServices(services => services.AddRouting()) + .Configure(app => + { + app.UseRouting(); + app.Use(async (context, next) => + { + labels.Add(HttpRouteLabel.Resolve(context)); + await next(context); + }); + app.UseEndpoints(endpoints => endpoints.MapPost("/tg-chat/{id:long}", (long id) => Results.Ok(id))); + })); + } +} diff --git a/src/LinkTracker.Tests/Shared/Integration/Infrastructure/Telemetry/MetricsEndpointTests.cs b/src/LinkTracker.Tests/Shared/Integration/Infrastructure/Telemetry/MetricsEndpointTests.cs index ee5b04f..9a30d91 100644 --- a/src/LinkTracker.Tests/Shared/Integration/Infrastructure/Telemetry/MetricsEndpointTests.cs +++ b/src/LinkTracker.Tests/Shared/Integration/Infrastructure/Telemetry/MetricsEndpointTests.cs @@ -144,7 +144,7 @@ private static TestServer CreateServer(string meterName) .ConfigureServices(services => { services.AddRouting(); - services.AddIpRateLimiting(configuration); + services.AddApiRateLimiting(configuration); services.AddOpenTelemetryMetrics("test", meterName); }) .Configure(app => diff --git a/src/LinkTracker.Tests/Shared/Unit/Infrastructure/Configuration/DotEnvConfigurationTests.cs b/src/LinkTracker.Tests/Shared/Unit/Infrastructure/Configuration/DotEnvConfigurationTests.cs new file mode 100644 index 0000000..624f208 --- /dev/null +++ b/src/LinkTracker.Tests/Shared/Unit/Infrastructure/Configuration/DotEnvConfigurationTests.cs @@ -0,0 +1,59 @@ +using LinkTracker.EnvReader; +using Microsoft.Extensions.Configuration; + +namespace LinkTracker.Tests.Shared.Unit.Infrastructure.Configuration; + +[Trait("Module", "Shared")] +[Trait("Category", "Unit")] +public sealed class DotEnvConfigurationTests : IDisposable +{ + private const string Key = "Scrapper:BaseUrl"; + private const string EnvironmentVariableName = "Scrapper__BaseUrl"; + + private readonly string _path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.env"); + + [Fact] + public void Build_WhenKeyIsAlsoSetAsEnvironmentVariable_PrefersEnvironmentVariable() + { + File.WriteAllText(_path, $"{EnvironmentVariableName}=http://from-dot-env"); + Environment.SetEnvironmentVariable(EnvironmentVariableName, "http://from-environment"); + + var configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddDotEnv(_path) + .Build(); + + Assert.Equal("http://from-environment", configuration[Key]); + } + + [Fact] + public void Build_WhenKeyIsOnlySetInDotEnv_UsesDotEnvValue() + { + File.WriteAllText(_path, $"{EnvironmentVariableName}=http://from-dot-env"); + + var configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddDotEnv(_path) + .Build(); + + Assert.Equal("http://from-dot-env", configuration[Key]); + } + + [Fact] + public void Build_WhenDotEnvIsAddedWithoutEnvironmentVariables_UsesDotEnvValue() + { + File.WriteAllText(_path, $"{EnvironmentVariableName}=http://from-dot-env"); + + var configuration = new ConfigurationBuilder() + .AddDotEnv(_path) + .Build(); + + Assert.Equal("http://from-dot-env", configuration[Key]); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(EnvironmentVariableName, null); + File.Delete(_path); + } +}