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

246 lines
9.6 KiB
C#
Raw Normal View History

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
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 partial 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);
readonly SemaphoreSlim tracing = new(4, 4);
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() };
var route = Trace(caller, ct);
foreach (var port in ports) result.Ports.Add(new ViewProbePort { Port = port, Outcome = await Knock(ticket, port, ct) });
result.Route = await route;
log.LogInformation("{Service} ran {Count} port(s)", nameof(ProbeService), ports.Count);
return (result, null, StatusCodes.Status200OK);
}
finally
{
running.TryRemove(network, out _);
}
}
async Task<List<ViewHop>> Trace(IPAddress target, CancellationToken ct)
{
var hops = new List<ViewHop>();
await tracing.WaitAsync(ct);
try
{
var info = new ProcessStartInfo("tracepath") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
foreach (var argument in new[] { "-n", "-m", "16", target.ToString() }) info.ArgumentList.Add(argument);
using var process = Process.Start(info);
if (process is null) return hops;
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(25));
try
{
while (await process.StandardOutput.ReadLineAsync(timeout.Token) is { } line)
{
var match = HopLine().Match(line);
if (!match.Success || !IPAddress.TryParse(match.Groups[2].Value, out var address)) continue;
var distance = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
if (hops.Any(h => h.Distance == distance)) continue;
if (!IsPublic(address) || Refuse(address) is not null) continue;
hops.Add(new ViewHop { Distance = distance, Address = address.ToString(), Milliseconds = double.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture) });
}
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
}
finally
{
if (!process.HasExited) process.Kill();
}
await Task.WhenAll(hops.Select(async hop =>
{
try
{
using var lookup = CancellationTokenSource.CreateLinkedTokenSource(ct);
lookup.CancelAfter(TimeSpan.FromSeconds(2));
var entry = await Dns.GetHostEntryAsync(hop.Address, lookup.Token);
if (!string.IsNullOrWhiteSpace(entry.HostName) && entry.HostName != hop.Address) hop.Name = entry.HostName;
}
catch (Exception) when (!ct.IsCancellationRequested)
{
}
}));
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
{
log.LogWarning("{Service} could not trace: {Error}", nameof(ProbeService), ex.Message);
}
finally
{
tracing.Release();
}
return hops;
}
[GeneratedRegex(@"^\s*(\d+):\s+([0-9a-fA-F.:]+)\s+([\d.]+)ms")]
internal static partial Regex HopLine();
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; }
}
}