- IConnectedAddresses remembers which address each host's last connection reached, set in SafeHttpHandlerFactory's connect callback. That is once per pooled connection, with no second DNS lookup, and the address is never stored. - IGeoLocator / DbIpLocator reads the offline DB-IP Lite city and ASN databases (MaxMind .mmdb, via MaxMind.Db). It maps memory, swaps to new files within ten minutes, rounds coordinates to one decimal, never looks up a private address, and answers nothing when the files are missing. CdnNetworks names the CDNs whose edge addresses say nothing about where a server is. - deploy/max/geo-update.sh fetches this or last month's databases, checks them and swaps them in atomically. The privapub-geo timer runs it monthly as www-data, and setup.sh installs the directory, the script, the units and a first download. Describing servers will use these in M8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
116 lines
4.5 KiB
C#
116 lines
4.5 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,
|
|
IConnectedAddresses connected = default)
|
|
{
|
|
var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
|
|
var services = new ServiceCollection();
|
|
services.AddHttpClient(FederationHttp.ClientName)
|
|
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options, connected));
|
|
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;
|
|
}
|
|
}
|