InboxReceiver records each answer once, in a finally, with a reason: too-large, not-json, not-activity, missing-type-or-actor, no-signature, the signature check's own codes (headers-unsigned, digest-mismatch, header-unreadable, date-skew, expired, algorithm-unsupported), signature-invalid, actor-not-key-owner, key-unavailable, id-cross-origin, undo-foreign, misattributed, unknown-recipient, and for 202s queued, duplicate, suspended or self-delete-unknown-key. Each event carries the activity and object type, the inbox, the signature scheme, the bytes and the time taken. A 404 for an unknown /mouth and a rate-limited inbox (429, from OnRejected) are recorded too. Until the signature verifies, the host is only claimed, so it is kept only if the server is already known. A suspended server is recorded under its own name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
61 lines
2.2 KiB
C#
61 lines
2.2 KiB
C#
using Microsoft.AspNetCore.RateLimiting;
|
|
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Federation.Signing;
|
|
using PrivaPub.Infrastructure.Statistics;
|
|
using PrivaPub.Models.Statistics;
|
|
|
|
using System.Threading.RateLimiting;
|
|
|
|
namespace PrivaPub.Infrastructure
|
|
{
|
|
public static class RateLimiting
|
|
{
|
|
public const string Accounts = "accounts";
|
|
public const string Inbox = "inbox";
|
|
|
|
public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service) =>
|
|
service.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
options.OnRejected = (context, _) =>
|
|
{
|
|
var http = context.HttpContext;
|
|
var ledger = http.RequestServices.GetService<IInteractionLedger>();
|
|
var policy = http.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName;
|
|
if (policy == Inbox)
|
|
ledger?.Record(new InteractionEvent
|
|
{
|
|
Channel = Interactions.Receive,
|
|
Host = Interactions.HostOf(SenderOrigin(http.Request)),
|
|
Status = StatusCodes.Status429TooManyRequests,
|
|
Outcome = Interactions.Refused,
|
|
Reason = "rate-limited",
|
|
Inbox = http.Request.Path.Value?.EndsWith("/mouth", StringComparison.Ordinal) == true ? "personal" : "shared"
|
|
}, hostClaimed: true);
|
|
else
|
|
ledger?.CountServer(ServerSections.Client, $"{policy ?? "unknown"}:429");
|
|
return ValueTask.CompletedTask;
|
|
};
|
|
options.AddPolicy(Accounts, context => RateLimitPartition.GetFixedWindowLimiter(
|
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
_ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
|
|
options.AddPolicy(Inbox, context => RateLimitPartition.GetTokenBucketLimiter(
|
|
SenderOrigin(context.Request) ?? "unsigned:" + context.Connection.RemoteIpAddress,
|
|
_ => new TokenBucketRateLimiterOptions
|
|
{
|
|
TokenLimit = 300,
|
|
TokensPerPeriod = 50,
|
|
ReplenishmentPeriod = TimeSpan.FromSeconds(10),
|
|
QueueLimit = 0
|
|
}));
|
|
});
|
|
|
|
static string SenderOrigin(HttpRequest request)
|
|
{
|
|
var signature = request.Headers["Signature"].ToString();
|
|
return string.IsNullOrEmpty(signature) ? default : Origin.Of(HttpSignatures.Parse(signature)?.KeyId);
|
|
}
|
|
}
|
|
}
|