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 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 options, ILogger log) { static readonly TimeSpan TicketLife = TimeSpan.FromSeconds(60); readonly ConcurrentDictionary tickets = new(); readonly ConcurrentDictionary> issued = new(); readonly ConcurrentDictionary 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> Trace(IPAddress target, CancellationToken ct) { var hops = new List(); await tracing.WaitAsync(ct); try { var info = new ProcessStartInfo("stdbuf") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; foreach (var argument in new[] { "-oL", "tracepath", "-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 { var silent = 0; while (await process.StandardOutput.ReadLineAsync(timeout.Token) is { } line) { if (SilentLine().IsMatch(line) && ++silent >= 3) break; var match = HopLine().Match(line); if (!match.Success || !IPAddress.TryParse(match.Groups[2].Value, out var address)) continue; silent = 0; 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(); [GeneratedRegex(@"^\s*\d+:\s+no reply")] internal static partial Regex SilentLine(); async Task 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; } } }