- HttpScope (AsyncLocal) tags each outbound request with a purpose and a trigger:
- purpose is set by the caller: actor, key, object, webfinger, context, nodeinfo;
- trigger is set by the job kind, by "verify" during inbox verification, or defaults
to "request".
- FederationHttp records every JSON, media and stream fetch: status, time, bytes, hops,
and an outcome of ok, refused or failed, with a reason: disallowed, remembered,
bad-redirect, too-many-redirects, content-type, too-large, bad-json, private-address,
timeout, network, or the status. A fetch a reader caused (trigger "request") is only
counted per server per day.
- Link previews record a 'preview' event: card, no-card or failed.
- The media proxy counts cache hits.
- TrafficMeter counts the client API per endpoint group, method and status class. It
counts our served documents (actor, outbox, collection, object, activity, licence,
webfinger, nodeinfo) by kind, status and whether signed, per day and never per server,
and never names a circle's collections.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
115 lines
4.4 KiB
C#
115 lines
4.4 KiB
C#
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
using PrivaPub.Federation.Moderation;
|
|
using PrivaPub.Infrastructure.Http;
|
|
using PrivaPub.Infrastructure.Statistics;
|
|
using PrivaPub.Models.Federation;
|
|
|
|
using System.Collections.Concurrent;
|
|
|
|
namespace PrivaPub.Tests.Support
|
|
{
|
|
public sealed class Peer : IAsyncDisposable
|
|
{
|
|
readonly WebApplication _app;
|
|
readonly ConcurrentDictionary<string, (string Text, string ContentType)> _documents = new();
|
|
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
|
|
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
|
|
|
|
public int Port { get; }
|
|
public string A => $"http://127.0.0.1:{Port}";
|
|
public string B => $"http://localhost:{Port}";
|
|
public ConcurrentQueue<HttpRequestRecord> Requests { get; } = new();
|
|
|
|
Peer(WebApplication app, int port)
|
|
{
|
|
_app = app;
|
|
Port = port;
|
|
}
|
|
|
|
public static async Task<Peer> Start()
|
|
{
|
|
var builder = WebApplication.CreateSlimBuilder();
|
|
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
|
var app = builder.Build();
|
|
Peer peer = default;
|
|
app.Run(async context =>
|
|
{
|
|
var body = context.Request.ContentLength > 0 || context.Request.Headers.TransferEncoding.Count > 0
|
|
? await new StreamReader(context.Request.Body).ReadToEndAsync()
|
|
: default;
|
|
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString(), body,
|
|
context.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase)));
|
|
var key = context.Request.Path.Value;
|
|
if (peer._files.TryGetValue(key, out var file))
|
|
{
|
|
await Results.Bytes(file.Bytes, file.ContentType, enableRangeProcessing: true).ExecuteAsync(context);
|
|
return;
|
|
}
|
|
if (peer._answers.TryGetValue(key, out var answer))
|
|
{
|
|
if (answer.Delay > TimeSpan.Zero)
|
|
await Task.Delay(answer.Delay);
|
|
context.Response.StatusCode = answer.Status;
|
|
return;
|
|
}
|
|
if (!peer._documents.TryGetValue(key, out var document))
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
|
return;
|
|
}
|
|
context.Response.ContentType = document.ContentType;
|
|
await context.Response.WriteAsync(document.Text.Replace("{A}", peer.A).Replace("{B}", peer.B));
|
|
});
|
|
await app.StartAsync();
|
|
peer = new Peer(app, new Uri(app.Urls.First()).Port);
|
|
return peer;
|
|
}
|
|
|
|
public void Serve(string path, string json) => _documents[path] = (json, "application/activity+json");
|
|
|
|
public void ServeText(string path, string text, string contentType) => _documents[path] = (text, contentType);
|
|
|
|
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
|
|
|
|
public void Answer(string path, int status, TimeSpan delay = default) => _answers[path] = (status, delay);
|
|
|
|
public static FederationHttp Http(IMemoryCache cache = default, IDomainBlocks blocks = default, IInteractionLedger ledger = default)
|
|
{
|
|
var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
|
|
var services = new ServiceCollection();
|
|
services.AddHttpClient(FederationHttp.ClientName)
|
|
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
|
|
return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
|
|
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
|
|
blocks ?? new NoBlocks(), NullLogger<FederationHttp>.Instance, ledger);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
|
|
}
|
|
|
|
public sealed class NoBlocks : IDomainBlocks
|
|
{
|
|
public DomainBlock Find(string host) => default;
|
|
public bool IsSuspended(string host) => false;
|
|
public Task Reload(CancellationToken token) => Task.CompletedTask;
|
|
}
|
|
|
|
public sealed record HttpRequestRecord(string Method, string Path, string Signature, string Body = default,
|
|
IReadOnlyDictionary<string, string> Headers = default);
|
|
|
|
public sealed class StaticOptions<T> : IOptionsMonitor<T>
|
|
{
|
|
public StaticOptions(T value) => CurrentValue = value;
|
|
public T CurrentValue { get; }
|
|
public T Get(string name) => CurrentValue;
|
|
public IDisposable OnChange(Action<T, string> listener) => default;
|
|
}
|
|
}
|