Files
MeWeb/src/MeWeb.Server/ProbeService.cs
T

181 lines
7.1 KiB
C#
Raw Normal View History

using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using MeWeb.Shared;
using Microsoft.Extensions.Options;
namespace MeWeb.Server;
public class ProbeOptions
{
public List<string> Refuse { get; set; } = [];
public bool AllowPrivate { get; set; }
public int PerHour { get; set; } = 6;
public int PerDay { get; set; } = 30;
}
public class ProbeService(IOptionsMonitor<ProbeOptions> options, ILogger<ProbeService> log)
{
static readonly TimeSpan TicketLife = TimeSpan.FromSeconds(60);
readonly ConcurrentDictionary<string, Ticket> tickets = new();
readonly ConcurrentDictionary<string, List<DateTimeOffset>> issued = new();
readonly ConcurrentDictionary<string, byte> running = new();
readonly SemaphoreSlim outbound = new(32, 32);
public (ViewProbeTicket Ticket, ViewRefusal Refusal, int Status) Issue(IPAddress caller)
{
var refusal = Refuse(caller);
if (refusal is not null) return (null, new ViewRefusal { Reason = refusal }, StatusCodes.Status403Forbidden);
var now = DateTimeOffset.UtcNow;
foreach (var (id, old) in tickets)
if (old.Expires < now) tickets.TryRemove(id, out _);
var network = NetworkOf(caller);
var times = issued.GetOrAdd(network, _ => []);
lock (times)
{
times.RemoveAll(t => now - t > TimeSpan.FromDays(1));
var lastHour = times.Count(t => now - t < TimeSpan.FromHours(1));
if (lastHour >= options.CurrentValue.PerHour || times.Count >= options.CurrentValue.PerDay)
{
var retry = lastHour >= options.CurrentValue.PerHour ? times.Where(t => now - t < TimeSpan.FromHours(1)).Min().AddHours(1) : times.Min().AddDays(1);
return (null, new ViewRefusal { Reason = "too-many", RetryAfterSeconds = (int)Math.Ceiling((retry - now).TotalSeconds) }, StatusCodes.Status429TooManyRequests);
}
times.Add(now);
}
var ticket = new Ticket
{
Id = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(12)),
Nonce = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(12)),
Key = RandomNumberGenerator.GetBytes(32),
Address = caller,
Expires = now + TicketLife,
};
tickets[ticket.Id] = ticket;
return (new ViewProbeTicket { Id = ticket.Id, Nonce = ticket.Nonce, Key = Convert.ToBase64String(ticket.Key), Address = caller.ToString(), Seconds = (int)TicketLife.TotalSeconds }, null, StatusCodes.Status200OK);
}
public async Task<(ViewProbe Probe, ViewRefusal Refusal, int Status)> Run(string id, IPAddress caller, ProbeForm form, CancellationToken ct)
{
if (id is null || !tickets.TryRemove(id, out var ticket) || ticket.Expires < DateTimeOffset.UtcNow)
return (null, new ViewRefusal { Reason = "unknown-ticket" }, StatusCodes.Status404NotFound);
if (!ticket.Address.Equals(caller)) return (null, new ViewRefusal { Reason = "other-address" }, StatusCodes.Status403Forbidden);
var ports = form?.Ports.Distinct().ToList() ?? [];
if (ports.Count is 0 or > 3 || ports.Any(p => p is not (80 or 443) and not (>= 49152 and <= 65535)))
return (null, new ViewRefusal { Reason = "bad-ports" }, StatusCodes.Status400BadRequest);
var network = NetworkOf(caller);
if (!running.TryAdd(network, 0)) return (null, new ViewRefusal { Reason = "busy" }, StatusCodes.Status409Conflict);
try
{
var result = new ViewProbe { Address = caller.ToString() };
foreach (var port in ports) result.Ports.Add(new ViewProbePort { Port = port, Outcome = await Knock(ticket, port, ct) });
log.LogInformation("{Service} ran {Count} port(s)", nameof(ProbeService), ports.Count);
return (result, null, StatusCodes.Status200OK);
}
finally
{
running.TryRemove(network, out _);
}
}
async Task<ProbeOutcome> Knock(Ticket ticket, int port, CancellationToken ct)
{
await outbound.WaitAsync(ct);
try
{
using var client = new TcpClient(ticket.Address.AddressFamily);
using (var connect = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
connect.CancelAfter(TimeSpan.FromSeconds(3));
try
{
await client.ConnectAsync(ticket.Address, port, connect.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return ProbeOutcome.TimedOut;
}
catch (SocketException ex) when (ex.SocketErrorCode is SocketError.ConnectionRefused)
{
return ProbeOutcome.Refused;
}
catch (SocketException)
{
return ProbeOutcome.TimedOut;
}
}
using var answer = CancellationTokenSource.CreateLinkedTokenSource(ct);
answer.CancelAfter(TimeSpan.FromSeconds(2));
try
{
var stream = client.GetStream();
await stream.WriteAsync(Encoding.ASCII.GetBytes($"{ProbeProof.Greeting} {ticket.Nonce} {port}\n"), answer.Token);
var buffer = new byte[96];
var length = 0;
while (length < buffer.Length && Array.IndexOf(buffer, (byte)'\n', 0, length) < 0)
{
var read = await stream.ReadAsync(buffer.AsMemory(length), answer.Token);
if (read == 0) break;
length += read;
}
var reply = Encoding.ASCII.GetString(buffer, 0, length).Trim();
var expected = ProbeProof.For(ticket.Key, ticket.Nonce, port);
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(reply), Encoding.ASCII.GetBytes(expected)) ? ProbeOutcome.Reached : ProbeOutcome.OpenNotYou;
}
catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException && !ct.IsCancellationRequested)
{
return ProbeOutcome.OpenNotYou;
}
}
finally
{
outbound.Release();
}
}
string Refuse(IPAddress caller)
{
var settings = options.CurrentValue;
if (!settings.AllowPrivate && !IsPublic(caller)) return "not-public";
foreach (var text in settings.Refuse)
if (IPNetwork.TryParse(text, out var network) && network.Contains(caller)) return "own-network";
return null;
}
static bool IsPublic(IPAddress address)
{
if (IPAddress.IsLoopback(address) || address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || address.IsIPv6UniqueLocal || address.IsIPv6Multicast) return false;
if (address.AddressFamily == AddressFamily.InterNetworkV6) return (address.GetAddressBytes()[0] & 0xe0) == 0x20;
var b = address.GetAddressBytes();
return b switch
{
[0 or 10 or 127, ..] => false,
[100, >= 64 and <= 127, ..] => false,
[169, 254, ..] or [192, 168, ..] or [172, >= 16 and <= 31, ..] => false,
[>= 224, ..] => false,
_ => true,
};
}
public static string NetworkOf(IPAddress address)
{
if (address.AddressFamily != AddressFamily.InterNetworkV6) return address.ToString();
var bytes = address.GetAddressBytes();
Array.Clear(bytes, 8, 8);
return new IPAddress(bytes) + "/64";
}
sealed class Ticket
{
public string Id { get; set; }
public string Nonce { get; set; }
public byte[] Key { get; set; }
public IPAddress Address { get; set; }
public DateTimeOffset Expires { get; set; }
}
}