- With the test from outside, the route from the publisher's server back to your address came back empty: the trace program holds its output until it ends, and it was stopped first while waiting on routers that never answer. It now reports each router as soon as it answers and stops after three silent ones, so the routers in front of your provider's exit reach the app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
253 lines
9.9 KiB
C#
253 lines
9.9 KiB
C#
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("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<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; }
|
|
}
|
|
}
|