From bd2f694cfd2fdfae54cdf03aa5091f8d1231f93c Mon Sep 17 00:00:00 2001 From: Alex Macocian Date: Fri, 16 Aug 2024 19:24:09 +0200 Subject: [PATCH] Improve README IPExtractingMiddleware --- Net.Sdk.Web.Extensions.sln | 3 +- .../HttpContextExtensions.cs | 19 +++ .../Middleware/IPExtractingMiddleware.cs | 34 ++++ .../Net.Sdk.Web.Extensions.csproj | 2 +- .../WebApplicationBuilderExtensions.cs | 14 +- .../WebApplicationExtensions.cs | 8 + README.md | 161 +++++++++++++++++- 7 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 Net.Sdk.Web.Extensions/Middleware/IPExtractingMiddleware.cs diff --git a/Net.Sdk.Web.Extensions.sln b/Net.Sdk.Web.Extensions.sln index 2186af4..a96077a 100644 --- a/Net.Sdk.Web.Extensions.sln +++ b/Net.Sdk.Web.Extensions.sln @@ -3,11 +3,12 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.9.34714.143 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Net.Sdk.Web.Extensions", "Net.Sdk.Web.Extensions\Net.Sdk.Web.Extensions.csproj", "{D0570CEC-F740-4332-AC10-8491974D1827}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Net.Sdk.Web.Extensions", "Net.Sdk.Web.Extensions\Net.Sdk.Web.Extensions.csproj", "{D0570CEC-F740-4332-AC10-8491974D1827}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution", "Solution", "{584B002D-4DFC-4034-8F3E-B4DA327B82B9}" ProjectSection(SolutionItems) = preProject LICENSE = LICENSE + README.md = README.md EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{EF036121-B1EE-4F1C-AF84-7E39337BF039}" diff --git a/Net.Sdk.Web.Extensions/HttpContextExtensions.cs b/Net.Sdk.Web.Extensions/HttpContextExtensions.cs index f40a713..12c8104 100644 --- a/Net.Sdk.Web.Extensions/HttpContextExtensions.cs +++ b/Net.Sdk.Web.Extensions/HttpContextExtensions.cs @@ -6,6 +6,7 @@ namespace Net.Sdk.Web; public static class HttpContextExtensions { private const string CorrelationVectorKey = "CorrelationVector"; + private const string ClientIPKey = "ClientIP"; public static void SetCorrelationVector(this HttpContext context, CorrelationVector cv) { @@ -24,4 +25,22 @@ public static class HttpContextExtensions return cv; } + + public static void SetClientIP(this HttpContext context, string ip) + { + context.ThrowIfNull() + .Items.Add(ClientIPKey, ip); + } + + public static string GetClientIP(this HttpContext context) + { + context.ThrowIfNull(); + if (!context.Items.TryGetValue(ClientIPKey, out var ip) || + ip is not string ipStr) + { + throw new InvalidOperationException("Unable to extract IP from context"); + } + + return ipStr; + } } diff --git a/Net.Sdk.Web.Extensions/Middleware/IPExtractingMiddleware.cs b/Net.Sdk.Web.Extensions/Middleware/IPExtractingMiddleware.cs new file mode 100644 index 0000000..1844695 --- /dev/null +++ b/Net.Sdk.Web.Extensions/Middleware/IPExtractingMiddleware.cs @@ -0,0 +1,34 @@ +namespace Net.Sdk.Web.Middleware; + +public sealed class IPExtractingMiddleware : IMiddleware +{ + private const string XForwardedForHeaderKey = "X-Forwarded-For"; + private const string CFConnectingIPHeaderKey = "CF-Connecting-IP"; + + public IPExtractingMiddleware() + { + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + var address = context.Connection.RemoteIpAddress?.ToString(); + context.Request.Headers.TryGetValue(CFConnectingIPHeaderKey, out var cfConnectingIpValues); + if (cfConnectingIpValues.FirstOrDefault() is string xCfIpAddress) + { + context.SetClientIP(xCfIpAddress); + await next(context); + return; + } + + context.Request.Headers.TryGetValue(XForwardedForHeaderKey, out var xForwardedForValues); + if (xForwardedForValues.FirstOrDefault() is string xForwardedIpAddress) + { + context.SetClientIP(xForwardedIpAddress); + await next(context); + return; + } + + context.SetClientIP(address ?? throw new InvalidOperationException("Unable to extract client IP address")); + await next(context); + } +} diff --git a/Net.Sdk.Web.Extensions/Net.Sdk.Web.Extensions.csproj b/Net.Sdk.Web.Extensions/Net.Sdk.Web.Extensions.csproj index 0193d53..f1af2ed 100644 --- a/Net.Sdk.Web.Extensions/Net.Sdk.Web.Extensions.csproj +++ b/Net.Sdk.Web.Extensions/Net.Sdk.Web.Extensions.csproj @@ -6,7 +6,7 @@ enable Library true - 0.6.1 + 0.7 Alexandru Macocian LICENSE true diff --git a/Net.Sdk.Web.Extensions/WebApplicationBuilderExtensions.cs b/Net.Sdk.Web.Extensions/WebApplicationBuilderExtensions.cs index befcc9a..fd7251b 100644 --- a/Net.Sdk.Web.Extensions/WebApplicationBuilderExtensions.cs +++ b/Net.Sdk.Web.Extensions/WebApplicationBuilderExtensions.cs @@ -31,14 +31,22 @@ public static class WebApplicationBuilderExtensions return builder; } - public static Net.Sdk.Web.Extensions.Http.HttpClientBuilder RegisterHttpClient(this WebApplicationBuilder builder) + public static WebApplicationBuilder WithIPExtraction(this WebApplicationBuilder builder) + { + builder.ThrowIfNull(); + builder.Services.AddScoped(); + + return builder; + } + + public static Extensions.Http.HttpClientBuilder RegisterHttpClient(this WebApplicationBuilder builder) where T : class { _ = builder.ThrowIfNull(); - return new Net.Sdk.Web.Extensions.Http.HttpClientBuilder(builder); + return new Extensions.Http.HttpClientBuilder(builder); } - public static Net.Sdk.Web.Extensions.Http.HttpClientBuilder WithCorrelationVector(this Net.Sdk.Web.Extensions.Http.HttpClientBuilder httpClientBuilder) + public static Extensions.Http.HttpClientBuilder WithCorrelationVector(this Extensions.Http.HttpClientBuilder httpClientBuilder) where T : class { return httpClientBuilder.ThrowIfNull() diff --git a/Net.Sdk.Web.Extensions/WebApplicationExtensions.cs b/Net.Sdk.Web.Extensions/WebApplicationExtensions.cs index 14b4125..63ac316 100644 --- a/Net.Sdk.Web.Extensions/WebApplicationExtensions.cs +++ b/Net.Sdk.Web.Extensions/WebApplicationExtensions.cs @@ -19,6 +19,14 @@ public static class WebApplicationExtensions return webApplication; } + public static WebApplication UseIPExtraction(this WebApplication webApplication) + { + webApplication.ThrowIfNull() + .UseMiddleware(); + + return webApplication; + } + public static WebApplication MapWebSocket<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TWebSocketRoute>(this WebApplication app, string route) where TWebSocketRoute : WebSocketRouteBase { diff --git a/README.md b/README.md index 928720b..4e4631c 100644 --- a/README.md +++ b/README.md @@ -1 +1,160 @@ -# AspNetCore.Extensions \ No newline at end of file +# Net.Sdk.Web.Extensions + +.NET library extending functionality for web applications + +# Features +- [Improved Websocket Support](#better-websocket-support) +- [Improved request tracing](#request-tracing-using-correlationvectors) +- [Improved client IP handling](#client-ip-extraction) +- [Improved configuration binding](#better-configuration-binding-support) +- [Mockable IHttpClient builder](#mockable-httpclient) +- [Support for base64 encoded certificates in configuration](#base64-to-x509certificate2-converter-for-options) + +## Better WebSocket support + +Net.Sdk.Web.Extensions provides a streamlined implementation of WebSockets to be used in a web app, in a syntax similar to Asp Mvc + +### Integration into `WebApplication` +```C# +var builder = WebApplication.CreateSlimBuilder(args); +var app = builder.Build(); +app.MapWebSocker("custom-route"); +``` + +### Message mapping +#### Define your request +```C# +[WebSocketConverter, CustomRequest>] +public class CustomRequest{ +} +``` + +#### Define your response +```C# +[WebSocketConverter, CustomResponse>] +public class CustomResponse{ +} +``` + +#### Implement your WebSocketRoute +```C# +public class CustomRoute : WebSocketRouteBase +{ + public override Task SocketAccepted(CancellationToken cancellationToken) + { + return base.SocketAccepted(cancellationToken); + } + + public override Task SocketClosed() + { + return base.SocketClosed(); + } + + public override async Task ExecuteAsync(CustomRequest? request, CancellationToken cancellationToken) + { + await this.SendMessage(new BotResponse(), cancellationToken); + } +} +``` + +### Custom message converters +If you need specialized converters, implement your own `WebSocketMessageConverter` + +## Request tracing using CorrelationVectors + +### Integration into `WebApplication` +```C# +var builder = WebApplication.CreateSlimBuilder(args); +builder.WithCorrelationVector(); +var app = builder.Build(); +app.UseCorrelationVector(); +``` + +### Accessing the correlation vector +Take a dependency on `IHttpContextAccessor` and get the CV using the `HttpContextExtensions.GetCorrelationVector` method +```C# +var cv = accessor.HttpContext.GetCorrelationVector(); +``` + +### Configuration +Configure an instance of `CorrelationVectorOptions` to adjust the CV header + +### Requests and responses +- On a request, the `CorrelationVectorMiddleware` retrieves the CV from the request header if present, otherwise it creates a new one +- The CV is stored in `HttpContext.Items` under `CorrelationVector` key +- Log the CV to follow traces of one application flow under multiple operations and across http requests + +### Integration with `HttpClient` +- Pass the `CorrelationVectorHandler` to the `IHttpClientBuilder` to manage CVs across requests +- On each `HttpClient` request, the CV is added to the configured header +- After receiving the response, the `CorrelationVectorHandler` will receive and parse any existing CV from the headers and reapply it to the `HttpContext.Items` + +## Client IP extraction +`IPExtractingMiddleware` figures out the IP of the client, being able to handle reverse proxying through `X-Forwarded-For` header as well as CloudFlare specific `CF-Connecting-IP` header + +### Integration into `WebApplication` +```C# +var builder = WebApplication.CreateSlimBuilder(args); +builder.WithIPExtraction(); +var app = builder.Build(); +app.UseIPExtraction(); +``` + +### Accessing the client IP +Take a dependency on `IHttpContextAccessor` and get the CV using the `HttpContextExtensions.GetCorrelationVector` method +```C# +var cv = accessor.HttpContext.GetClientIP(); +``` + +## Better configuration binding support + +### Integration into `WebApplication` +```C# +var builder = WebApplication.CreateSlimBuilder(args); +builder.ConfigureExtended(); + +public class CustomService +{ + public CustomService (IOptions options) + { + } +} +``` + +### Customizing configuration key +```C# +[OptionsName(Name = "CustomKey")] +public class CustomOptions +{ + +} +``` + +## Mockable HttpClient +### Integration into `WebApplication` +```C# +var builder = WebApplication.CreateSlimBuilder(args); +builder.RegisterHttpClient() + .WithTimeout(TimeSpan.FromSeconds(5)) + .WithCorrelationVector() + .CreateBuilder(); + +public class CustomService +{ + public CustomService (IHttpClient client) + { + } +} +``` + +## Base64 to X509Certificate2 converter for options +Use the `Base64ToCertificateConverter` to retrieve your SSL certificate from configuration and bind it to options, to allow for dynamic loading of certificates + +### Use `Base64ToCertificateConverter` on your `X509Certificate2` property +```C# +public class ServerOptions +{ + [JsonConverter(typeof(Base64ToCertificateConverter))] + public X509Certificate2 Certificate { get; set; } = default!; +} +``` \ No newline at end of file