The server's VAPID key (PushKeys, made on first need, in Mongo) is the instance API's vapid.public_key and every app's vapid_key. /api/v1/push/subscription (POST, GET, PUT, DELETE) keeps one subscription per access token, so per persona; every notification kept (never a filtered one) is queued as a SendPush job for the subscriptions whose alert and policy admit it, and sent through the `push` client (the safe handler, https only) encrypted as RFC 8291 aes128gcm or the aesgcm draft with an ES256 VAPID JWT. 404/410 delete the subscription, 429/5xx/timeouts retry, other answers drop the push. Subscriptions end with their tokens (revocation, sessions ended, suspension, deletion, a restore); backups hold neither keys nor subscriptions. The walk of every Mastodon 4.3-4.5 client route (docs/MASTODON-API.md) added conversations/:id/unread, domain_blocks/preview, v2 notifications clear and PUT policy, /oauth/userinfo and stubs, so both instance APIs now say 4.5.0 (compatible; PrivaPub <release>). The pasture gets a push sink (pushsink.test) and scenarios/push.sh in batch A. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
65 lines
2.1 KiB
C#
65 lines
2.1 KiB
C#
using System.Net;
|
|
using System.Net.Security;
|
|
using System.Net.Sockets;
|
|
|
|
namespace PrivaPub.Infrastructure.Http
|
|
{
|
|
public sealed class BlockedDestinationException : Exception
|
|
{
|
|
public BlockedDestinationException(string host) : base($"'{host}' does not resolve to a public address") { }
|
|
}
|
|
|
|
public static class SafeHttpHandlerFactory
|
|
{
|
|
public static SocketsHttpHandler Create(FederationOptions options, IConnectedAddresses connected = default, TimeSpan? connectTimeout = default) => new()
|
|
{
|
|
SslOptions = options.AcceptAnyCertificate
|
|
? new SslClientAuthenticationOptions { RemoteCertificateValidationCallback = (_, _, _, _) => true }
|
|
: new SslClientAuthenticationOptions(),
|
|
AllowAutoRedirect = false,
|
|
UseProxy = false,
|
|
UseCookies = false,
|
|
AutomaticDecompression = DecompressionMethods.All,
|
|
ConnectTimeout = connectTimeout ?? TimeSpan.FromSeconds(10),
|
|
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
|
MaxResponseHeadersLength = 64,
|
|
ConnectCallback = (context, token) => Connect(context.DnsEndPoint, options.AllowPrivateNetworks, token, connected)
|
|
};
|
|
|
|
public static async ValueTask<Stream> Connect(DnsEndPoint endPoint, bool allowPrivateNetworks, CancellationToken token,
|
|
IConnectedAddresses connected = default)
|
|
{
|
|
var addresses = await Resolve(endPoint.Host, token);
|
|
if (addresses.Length == 0 || !allowPrivateNetworks && !addresses.All(IpRangeGuard.IsPublic))
|
|
throw new BlockedDestinationException(endPoint.Host);
|
|
|
|
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
|
|
try
|
|
{
|
|
await socket.ConnectAsync(addresses, endPoint.Port, token);
|
|
connected?.Remember(endPoint.Host, (socket.RemoteEndPoint as IPEndPoint)?.Address);
|
|
return new NetworkStream(socket, ownsSocket: true);
|
|
}
|
|
catch
|
|
{
|
|
socket.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
static async Task<IPAddress[]> Resolve(string host, CancellationToken token)
|
|
{
|
|
if (IPAddress.TryParse(host.Trim('[', ']'), out var literal))
|
|
return new[] { literal };
|
|
try
|
|
{
|
|
return await Dns.GetHostAddressesAsync(host, token);
|
|
}
|
|
catch (SocketException)
|
|
{
|
|
return Array.Empty<IPAddress>();
|
|
}
|
|
}
|
|
}
|
|
}
|