181 lines
7.1 KiB
C#
181 lines
7.1 KiB
C#
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; }
|
||
|
|
}
|
||
|
|
}
|