M2: every inbox answer is recorded
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
This commit is contained in:
1 parent
15cd034b29
commit
4fa53f63bd
7 files changed
+385
-24
No files matched your search
@@ -248,7 +248,7 @@ namespace PrivaPub.Federation.Controllers
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local is not { IsFederated: true })
|
||||
return NotFound();
|
||||
return Answer(_inbox.NoSuchRecipient(Request));
|
||||
return Answer(await _inbox.Receive(Request, local, token));
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ namespace PrivaPub.Federation.Controllers
|
||||
IActionResult Answer(InboxResult result)
|
||||
{
|
||||
if (result.Error != default)
|
||||
_logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error);
|
||||
_logger.LogInformation("Inbox refused with {Status} ({Reason}): {Error}", result.StatusCode, result.Reason, result.Error);
|
||||
if (result.RetryAfterSeconds is { } seconds)
|
||||
Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
|
||||
|
||||
@@ -3,8 +3,10 @@ using PrivaPub.Federation.Moderation;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Signing;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Infrastructure.Statistics;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Statistics;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -13,7 +15,7 @@ using static PrivaPub.Federation.Objects.ActivityJson;
|
||||
|
||||
namespace PrivaPub.Federation.Inbox
|
||||
{
|
||||
public sealed record InboxResult(int StatusCode, string Error = default, int? RetryAfterSeconds = default);
|
||||
public sealed record InboxResult(int StatusCode, string Error = default, int? RetryAfterSeconds = default, string Reason = default);
|
||||
|
||||
public sealed record InboxPayload(string ActorURI, string Activity, string Inbox = default, string KeyId = default, string Algorithm = default,
|
||||
string[] SignedHeaders = default, DateTime? ReceivedAt = default);
|
||||
@@ -21,6 +23,7 @@ namespace PrivaPub.Federation.Inbox
|
||||
public interface IInboxReceiver
|
||||
{
|
||||
Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token);
|
||||
InboxResult NoSuchRecipient(HttpRequest request);
|
||||
}
|
||||
|
||||
public class InboxReceiver : IInboxReceiver
|
||||
@@ -34,27 +37,87 @@ namespace PrivaPub.Federation.Inbox
|
||||
readonly IJobQueue _queue;
|
||||
readonly IDomainBlocks _domainBlocks;
|
||||
readonly ILogger<InboxReceiver> _logger;
|
||||
readonly IInteractionLedger _ledger;
|
||||
|
||||
public InboxReceiver(ILocalActorService localActors, IRemoteActorService remoteActors, IJobQueue queue, IDomainBlocks domainBlocks,
|
||||
ILogger<InboxReceiver> logger)
|
||||
ILogger<InboxReceiver> logger, IInteractionLedger ledger = default)
|
||||
{
|
||||
_localActors = localActors;
|
||||
_remoteActors = remoteActors;
|
||||
_queue = queue;
|
||||
_domainBlocks = domainBlocks;
|
||||
_logger = logger;
|
||||
_ledger = ledger;
|
||||
}
|
||||
|
||||
sealed class Receipt
|
||||
{
|
||||
public string Type;
|
||||
public string ObjectType;
|
||||
public string ClaimedHost;
|
||||
public string VerifiedActor;
|
||||
public string Inbox;
|
||||
public string Signature = "none";
|
||||
public long? Bytes;
|
||||
public bool Blocked;
|
||||
}
|
||||
|
||||
public InboxResult NoSuchRecipient(HttpRequest request)
|
||||
{
|
||||
var result = new InboxResult(StatusCodes.Status404NotFound, "no such local actor", Reason: "unknown-recipient");
|
||||
var keyId = HttpSignatures.Parse(request.Headers["Signature"].ToString())?.KeyId;
|
||||
Record(new Receipt { ClaimedHost = HostOf(keyId), Inbox = "personal", Bytes = request.ContentLength }, result, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token)
|
||||
{
|
||||
var started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var receipt = new Receipt { Inbox = recipient == default ? "shared" : "personal", Bytes = request.ContentLength };
|
||||
InboxResult result = default;
|
||||
try
|
||||
{
|
||||
result = await Receive(request, recipient, receipt, token);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Record(receipt, result, (int)System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
void Record(Receipt receipt, InboxResult result, int latencyMs)
|
||||
{
|
||||
if (_ledger == default || result == default)
|
||||
return;
|
||||
var verified = receipt.VerifiedActor != default;
|
||||
_ledger.Record(new InteractionEvent
|
||||
{
|
||||
Channel = Interactions.Receive,
|
||||
Host = verified ? Interactions.HostOf(receipt.VerifiedActor) : receipt.ClaimedHost,
|
||||
Activity = receipt.Type,
|
||||
Object = receipt.ObjectType,
|
||||
Status = result.StatusCode,
|
||||
Outcome = result.StatusCode == StatusCodes.Status202Accepted ? Interactions.Queued : Interactions.Refused,
|
||||
Reason = result.Reason,
|
||||
LatencyMs = latencyMs,
|
||||
Bytes = receipt.Bytes,
|
||||
Inbox = receipt.Inbox,
|
||||
Signature = receipt.Signature
|
||||
}, verified ? receipt.VerifiedActor : default, hostClaimed: !verified && !receipt.Blocked);
|
||||
}
|
||||
|
||||
async Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, Receipt receipt, CancellationToken token)
|
||||
{
|
||||
if (request.ContentLength > MaxBodyBytes)
|
||||
return new(StatusCodes.Status413PayloadTooLarge);
|
||||
return new(StatusCodes.Status413PayloadTooLarge, Reason: "too-large");
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await request.Body.CopyToAsync(buffer, token);
|
||||
if (buffer.Length > MaxBodyBytes)
|
||||
return new(StatusCodes.Status413PayloadTooLarge);
|
||||
return new(StatusCodes.Status413PayloadTooLarge, Reason: "too-large");
|
||||
var body = buffer.ToArray();
|
||||
receipt.Bytes = body.Length;
|
||||
|
||||
JsonNode activity;
|
||||
try
|
||||
@@ -63,41 +126,51 @@ namespace PrivaPub.Federation.Inbox
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new(StatusCodes.Status400BadRequest, "the body is not JSON");
|
||||
return new(StatusCodes.Status400BadRequest, "the body is not JSON", Reason: "not-json");
|
||||
}
|
||||
if (activity is not JsonObject)
|
||||
return new(StatusCodes.Status400BadRequest, "the body is not an activity");
|
||||
return new(StatusCodes.Status400BadRequest, "the body is not an activity", Reason: "not-activity");
|
||||
|
||||
var type = Value(activity, "type");
|
||||
var actorUri = Id(activity["actor"]);
|
||||
receipt.Type = type;
|
||||
receipt.ObjectType = activity["object"] is JsonObject embedded ? Value(embedded, "type") : default;
|
||||
receipt.ClaimedHost = HostOf(actorUri);
|
||||
if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(actorUri))
|
||||
return new(StatusCodes.Status400BadRequest, "type and actor are required");
|
||||
return new(StatusCodes.Status400BadRequest, "type and actor are required", Reason: "missing-type-or-actor");
|
||||
|
||||
var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString());
|
||||
if (parameters == default)
|
||||
return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header");
|
||||
return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header", Reason: "no-signature");
|
||||
receipt.Signature = "cavage:" + parameters.Algorithm;
|
||||
receipt.ClaimedHost ??= HostOf(parameters.KeyId);
|
||||
if (_domainBlocks.IsSuspended(HostOf(parameters.KeyId)) || _domainBlocks.IsSuspended(HostOf(actorUri)))
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
{
|
||||
receipt.Blocked = true;
|
||||
receipt.ClaimedHost = _domainBlocks.IsSuspended(HostOf(actorUri)) ? HostOf(actorUri) : HostOf(parameters.KeyId);
|
||||
return new(StatusCodes.Status202Accepted, Reason: "suspended");
|
||||
}
|
||||
|
||||
var requestProblem = HttpSignatures.CheckRequest(request, parameters, body);
|
||||
if (requestProblem != default)
|
||||
return new(StatusCodes.Status401Unauthorized, requestProblem);
|
||||
return new(StatusCodes.Status401Unauthorized, requestProblem, Reason: HttpSignatures.ProblemCode(requestProblem));
|
||||
|
||||
var signingString = HttpSignatures.SigningString(request, parameters);
|
||||
var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token);
|
||||
if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri)
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
return new(StatusCodes.Status202Accepted, Reason: "self-delete-unknown-key");
|
||||
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
|
||||
{
|
||||
keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
|
||||
if (keyOwner == default && _remoteActors.KeyTemporarilyUnavailable(parameters.KeyId))
|
||||
return new(StatusCodes.Status503ServiceUnavailable, "the signing key could not be fetched; try again later", KeyRetrySeconds);
|
||||
return new(StatusCodes.Status503ServiceUnavailable, "the signing key could not be fetched; try again later", KeyRetrySeconds, "key-unavailable");
|
||||
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
|
||||
return new(StatusCodes.Status401Unauthorized, "the signature does not verify");
|
||||
return new(StatusCodes.Status401Unauthorized, "the signature does not verify", Reason: "signature-invalid");
|
||||
}
|
||||
|
||||
if (!string.Equals(keyOwner.ActorURI, actorUri, StringComparison.Ordinal))
|
||||
return new(StatusCodes.Status401Unauthorized, "the activity's actor is not the key's owner");
|
||||
return new(StatusCodes.Status401Unauthorized, "the activity's actor is not the key's owner", Reason: "actor-not-key-owner");
|
||||
receipt.VerifiedActor = actorUri;
|
||||
|
||||
var shapeProblem = await ShapeProblem(type, activity, actorUri, token);
|
||||
if (shapeProblem != default)
|
||||
@@ -106,19 +179,19 @@ namespace PrivaPub.Federation.Inbox
|
||||
var activityId = Id(activity);
|
||||
var payload = new InboxPayload(actorUri, activity.ToJsonString(), recipient == default ? "shared" : "personal", parameters.KeyId,
|
||||
parameters.Algorithm, parameters.Headers, DateTime.UtcNow);
|
||||
await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(payload),
|
||||
var queued = await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(payload),
|
||||
new Uri(actorUri).Host.ToLowerInvariant(), activityId == default ? default : "inbox|" + activityId, token);
|
||||
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
return new(StatusCodes.Status202Accepted, Reason: queued ? "queued" : "duplicate");
|
||||
}
|
||||
|
||||
static string HostOf(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? parsed.Host : default;
|
||||
static string HostOf(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? parsed.Host.ToLowerInvariant() : default;
|
||||
|
||||
async Task<InboxResult> ShapeProblem(string type, JsonNode activity, string actorUri, CancellationToken token)
|
||||
{
|
||||
var activityId = Id(activity);
|
||||
if (activityId != default && !Origin.Same(activityId, actorUri))
|
||||
return new(StatusCodes.Status400BadRequest, "the activity's id is not on its actor's origin");
|
||||
return new(StatusCodes.Status400BadRequest, "the activity's id is not on its actor's origin", Reason: "id-cross-origin");
|
||||
|
||||
var inner = activity["object"];
|
||||
switch (type)
|
||||
@@ -126,14 +199,14 @@ namespace PrivaPub.Federation.Inbox
|
||||
case "Follow":
|
||||
var target = await _localActors.FindByUri(Id(inner), token);
|
||||
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
|
||||
return new(StatusCodes.Status404NotFound, "no such local actor");
|
||||
return new(StatusCodes.Status404NotFound, "no such local actor", Reason: "unknown-recipient");
|
||||
break;
|
||||
case "Undo" when inner is JsonObject && Id(inner["actor"]) != actorUri:
|
||||
return new(StatusCodes.Status400BadRequest, "an actor can only undo its own activities");
|
||||
return new(StatusCodes.Status400BadRequest, "an actor can only undo its own activities", Reason: "undo-foreign");
|
||||
case "Create" or "Update" when inner is JsonObject && Origin.Same(Id(inner), actorUri)
|
||||
&& inner["attributedTo"] != default && Id(inner["attributedTo"]) != actorUri
|
||||
&& Value(inner, "type") is not ("Person" or "Service" or "Application" or "Group" or "Organization"):
|
||||
return new(StatusCodes.Status400BadRequest, "the object is not attributed to the actor");
|
||||
return new(StatusCodes.Status400BadRequest, "the object is not attributed to the actor", Reason: "misattributed");
|
||||
}
|
||||
return default;
|
||||
}
|
||||
|
||||
@@ -185,6 +185,18 @@ namespace PrivaPub.Federation.Signing
|
||||
return default;
|
||||
}
|
||||
|
||||
public static string ProblemCode(string problem) => problem switch
|
||||
{
|
||||
null => default,
|
||||
_ when problem.StartsWith("unsupported signature algorithm", StringComparison.Ordinal) => "algorithm-unsupported",
|
||||
_ when problem.EndsWith("is not signed", StringComparison.Ordinal) || problem.StartsWith("neither", StringComparison.Ordinal) => "headers-unsigned",
|
||||
_ when problem.StartsWith("the digest does not match", StringComparison.Ordinal) => "digest-mismatch",
|
||||
_ when problem.StartsWith("unreadable", StringComparison.Ordinal) => "header-unreadable",
|
||||
_ when problem.Contains("outside the allowed window", StringComparison.Ordinal) => "date-skew",
|
||||
"the signature has expired" => "expired",
|
||||
_ => "signature-problem"
|
||||
};
|
||||
|
||||
static bool IsFresh(DateTimeOffset signedAt, DateTimeOffset now) =>
|
||||
signedAt >= now - MaxAge && signedAt <= now + MaxClockSkew;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Signing;
|
||||
using PrivaPub.Infrastructure.Statistics;
|
||||
using PrivaPub.Models.Statistics;
|
||||
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
@@ -16,6 +18,25 @@ namespace PrivaPub.Infrastructure
|
||||
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 }));
|
||||
|
||||
Reference in new issue
Block a user