Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public static class EventWebhookEndpointExtensions
|
||||
{
|
||||
public static void MapSchedulerEventWebhookEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/events");
|
||||
|
||||
group.MapPost("/feedser-export", HandleFeedserExportAsync);
|
||||
group.MapPost("/vexer-export", HandleVexerExportAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleFeedserExportAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
[FromServices] IWebhookRequestAuthenticator authenticator,
|
||||
[FromServices] IWebhookRateLimiter rateLimiter,
|
||||
[FromServices] IInboundExportEventSink sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookOptions = options.CurrentValue.Webhooks.Feedser;
|
||||
if (!webhookOptions.Enabled)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var readResult = await ReadPayloadAsync<FeedserExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
|
||||
if (!readResult.Succeeded)
|
||||
{
|
||||
return readResult.ErrorResult!;
|
||||
}
|
||||
|
||||
if (!rateLimiter.TryAcquire("feedser", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
|
||||
{
|
||||
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
if (retryAfter > TimeSpan.Zero)
|
||||
{
|
||||
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
return authResult.ToResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await sink.HandleFeedserAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted(value: new { status = "accepted" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleVexerExportAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
[FromServices] IWebhookRequestAuthenticator authenticator,
|
||||
[FromServices] IWebhookRateLimiter rateLimiter,
|
||||
[FromServices] IInboundExportEventSink sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookOptions = options.CurrentValue.Webhooks.Vexer;
|
||||
if (!webhookOptions.Enabled)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var readResult = await ReadPayloadAsync<VexerExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
|
||||
if (!readResult.Succeeded)
|
||||
{
|
||||
return readResult.ErrorResult!;
|
||||
}
|
||||
|
||||
if (!rateLimiter.TryAcquire("vexer", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
|
||||
{
|
||||
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
if (retryAfter > TimeSpan.Zero)
|
||||
{
|
||||
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
return authResult.ToResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await sink.HandleVexerAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted(value: new { status = "accepted" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RequestPayload<T>> ReadPayloadAsync<T>(HttpContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Request.EnableBuffering();
|
||||
|
||||
await using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
var bodyBytes = buffer.ToArray();
|
||||
context.Request.Body.Position = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<T>(bodyBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
if (payload is null)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = "Request payload cannot be empty." }));
|
||||
}
|
||||
|
||||
return RequestPayload<T>.Success(payload, bodyBytes);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct RequestPayload<T>
|
||||
{
|
||||
private RequestPayload(T? payload, byte[] rawBody, IResult? error, bool succeeded)
|
||||
{
|
||||
Payload = payload;
|
||||
RawBody = rawBody;
|
||||
ErrorResult = error;
|
||||
Succeeded = succeeded;
|
||||
}
|
||||
|
||||
public T? Payload { get; }
|
||||
|
||||
public byte[] RawBody { get; }
|
||||
|
||||
public IResult? ErrorResult { get; }
|
||||
|
||||
public bool Succeeded { get; }
|
||||
|
||||
public static RequestPayload<T> Success(T payload, byte[] rawBody)
|
||||
=> new(payload, rawBody, null, true);
|
||||
|
||||
public static RequestPayload<T> Failed(IResult error)
|
||||
=> new(default, Array.Empty<byte>(), error, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public interface IInboundExportEventSink
|
||||
{
|
||||
Task HandleFeedserAsync(FeedserExportEventRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task HandleVexerAsync(VexerExportEventRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public interface IWebhookRateLimiter
|
||||
{
|
||||
bool TryAcquire(string key, int limit, TimeSpan window, out TimeSpan retryAfter);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public interface IWebhookRequestAuthenticator
|
||||
{
|
||||
Task<WebhookAuthenticationResult> AuthenticateAsync(HttpContext context, ReadOnlyMemory<byte> body, SchedulerWebhookOptions options, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class WebhookRequestAuthenticator : IWebhookRequestAuthenticator
|
||||
{
|
||||
private readonly ILogger<WebhookRequestAuthenticator> _logger;
|
||||
|
||||
public WebhookRequestAuthenticator(
|
||||
ILogger<WebhookRequestAuthenticator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<WebhookAuthenticationResult> AuthenticateAsync(HttpContext context, ReadOnlyMemory<byte> body, SchedulerWebhookOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return WebhookAuthenticationResult.Success();
|
||||
}
|
||||
|
||||
if (options.RequireClientCertificate)
|
||||
{
|
||||
var certificate = context.Connection.ClientCertificate ?? await context.Connection.GetClientCertificateAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (certificate is null)
|
||||
{
|
||||
_logger.LogWarning("Webhook {Name} rejected request without client certificate.", options.Name);
|
||||
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Client certificate required.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.HmacSecret))
|
||||
{
|
||||
var headerName = string.IsNullOrWhiteSpace(options.SignatureHeader) ? "X-Scheduler-Signature" : options.SignatureHeader;
|
||||
if (!context.Request.Headers.TryGetValue(headerName, out var signatureValues))
|
||||
{
|
||||
_logger.LogWarning("Webhook {Name} rejected request missing signature header {Header}.", options.Name, headerName);
|
||||
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Missing signature header.");
|
||||
}
|
||||
|
||||
var providedSignature = signatureValues.ToString();
|
||||
if (string.IsNullOrWhiteSpace(providedSignature))
|
||||
{
|
||||
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Signature header is empty.");
|
||||
}
|
||||
|
||||
if (!VerifySignature(body.Span, options.HmacSecret!, providedSignature))
|
||||
{
|
||||
_logger.LogWarning("Webhook {Name} rejected request with invalid signature.", options.Name);
|
||||
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Invalid signature.");
|
||||
}
|
||||
}
|
||||
|
||||
return WebhookAuthenticationResult.Success();
|
||||
}
|
||||
|
||||
private static bool VerifySignature(ReadOnlySpan<byte> payload, string secret, string providedSignature)
|
||||
{
|
||||
byte[] secretBytes;
|
||||
try
|
||||
{
|
||||
secretBytes = Convert.FromBase64String(secret);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
try
|
||||
{
|
||||
secretBytes = Convert.FromHexString(secret);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
secretBytes = Encoding.UTF8.GetBytes(secret);
|
||||
}
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA256(secretBytes);
|
||||
var hash = hmac.ComputeHash(payload.ToArray());
|
||||
var computedSignature = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(computedSignature),
|
||||
Encoding.UTF8.GetBytes(providedSignature.Trim()));
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct WebhookAuthenticationResult(bool Succeeded, int StatusCode, string? Message)
|
||||
{
|
||||
public static WebhookAuthenticationResult Success() => new(true, StatusCodes.Status200OK, null);
|
||||
|
||||
public static WebhookAuthenticationResult Fail(int statusCode, string message) => new(false, statusCode, message);
|
||||
|
||||
public IResult ToResult()
|
||||
=> Succeeded ? Results.Ok() : Results.Json(new { error = Message ?? "Unauthorized" }, statusCode: StatusCode);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
internal sealed class InMemoryWebhookRateLimiter : IWebhookRateLimiter, IDisposable
|
||||
{
|
||||
private readonly MemoryCache _cache = new(new MemoryCacheOptions());
|
||||
|
||||
private readonly object _mutex = new();
|
||||
|
||||
public bool TryAcquire(string key, int limit, TimeSpan window, out TimeSpan retryAfter)
|
||||
{
|
||||
if (limit <= 0)
|
||||
{
|
||||
retryAfter = TimeSpan.Zero;
|
||||
return true;
|
||||
}
|
||||
|
||||
retryAfter = TimeSpan.Zero;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
lock (_mutex)
|
||||
{
|
||||
if (!_cache.TryGetValue(key, out Queue<DateTimeOffset>? hits))
|
||||
{
|
||||
hits = new Queue<DateTimeOffset>();
|
||||
_cache.Set(key, hits, new MemoryCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = window.Add(window)
|
||||
});
|
||||
}
|
||||
|
||||
hits ??= new Queue<DateTimeOffset>();
|
||||
|
||||
while (hits.Count > 0 && now - hits.Peek() > window)
|
||||
{
|
||||
hits.Dequeue();
|
||||
}
|
||||
|
||||
if (hits.Count >= limit)
|
||||
{
|
||||
var oldest = hits.Peek();
|
||||
retryAfter = (oldest + window) - now;
|
||||
if (retryAfter < TimeSpan.Zero)
|
||||
{
|
||||
retryAfter = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
hits.Enqueue(now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
internal sealed class LoggingExportEventSink : IInboundExportEventSink
|
||||
{
|
||||
private readonly ILogger<LoggingExportEventSink> _logger;
|
||||
|
||||
public LoggingExportEventSink(ILogger<LoggingExportEventSink> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task HandleFeedserAsync(FeedserExportEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Received Feedser export webhook {ExportId} with {ChangedProducts} product keys.",
|
||||
request.ExportId,
|
||||
request.ChangedProductKeys.Count);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task HandleVexerAsync(VexerExportEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Received Vexer export webhook {ExportId} with {ChangedClaims} claim changes.",
|
||||
request.ExportId,
|
||||
request.ChangedClaims.Count);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public sealed record FeedserExportEventRequest(
|
||||
string ExportId,
|
||||
IReadOnlyList<string> ChangedProductKeys,
|
||||
IReadOnlyList<string>? Kev,
|
||||
WebhookEventWindow? Window)
|
||||
{
|
||||
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
|
||||
|
||||
public IReadOnlyList<string> ChangedProductKeys { get; } = NormalizeList(ChangedProductKeys, nameof(ChangedProductKeys));
|
||||
|
||||
public IReadOnlyList<string> Kev { get; } = NormalizeList(Kev, nameof(Kev), allowEmpty: true);
|
||||
|
||||
public WebhookEventWindow? Window { get; } = Window;
|
||||
|
||||
private static IReadOnlyList<string> NormalizeList(IReadOnlyList<string>? source, string propertyName, bool allowEmpty = false)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
if (allowEmpty)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
throw new ValidationException($"{propertyName} must be specified.");
|
||||
}
|
||||
|
||||
var cleaned = source
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => item.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (!allowEmpty && cleaned.Length == 0)
|
||||
{
|
||||
throw new ValidationException($"{propertyName} must contain at least one value.");
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VexerExportEventRequest(
|
||||
string ExportId,
|
||||
IReadOnlyList<VexerClaimChange> ChangedClaims,
|
||||
WebhookEventWindow? Window)
|
||||
{
|
||||
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
|
||||
|
||||
public IReadOnlyList<VexerClaimChange> ChangedClaims { get; } = NormalizeClaims(ChangedClaims);
|
||||
|
||||
public WebhookEventWindow? Window { get; } = Window;
|
||||
|
||||
private static IReadOnlyList<VexerClaimChange> NormalizeClaims(IReadOnlyList<VexerClaimChange>? claims)
|
||||
{
|
||||
if (claims is null || claims.Count == 0)
|
||||
{
|
||||
throw new ValidationException("changedClaims must contain at least one entry.");
|
||||
}
|
||||
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
claim.Validate();
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VexerClaimChange(
|
||||
string ProductKey,
|
||||
string VulnerabilityId,
|
||||
string Status)
|
||||
{
|
||||
public string ProductKey { get; } = Normalize(ProductKey, nameof(ProductKey));
|
||||
|
||||
public string VulnerabilityId { get; } = Normalize(VulnerabilityId, nameof(VulnerabilityId));
|
||||
|
||||
public string Status { get; } = Normalize(Status, nameof(Status));
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
_ = ProductKey;
|
||||
_ = VulnerabilityId;
|
||||
_ = Status;
|
||||
}
|
||||
|
||||
private static string Normalize(string value, string propertyName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ValidationException($"{propertyName} must be provided.");
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WebhookEventWindow(DateTimeOffset? From, DateTimeOffset? To);
|
||||
Reference in New Issue
Block a user