The checks engine, the path and the cards: me-web? 0.1 reads the connection
MeWeb.Core runs seven checks concurrently and reports each as it settles:
online or behind a login page, this connection, the public IPv4 and IPv6
seen from v4./v6.meweb, the provider from a built-in map, the router
(UPnP IGD, NAT-PMP and PCP, read-only), address sharing (router WAN
against the echo, TTL hops only as a hint), and the history kept on the
device. The app draws the path from this device to the internet and lights
it node by node, with a card per check and a sheet listing every host it
contacts.
The provider map is iptoasn's table compressed to 2.1 MB by
tools/knowledge.cs and built before every app release; eleven Italian
provider profiles carry each fact with who said it, the source and the
month. The server gains a download page (en, it) and /download/{platform}
from the latest Gitea release.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
16a1cecaae
commit
0dffbc27d6
61 files changed
+3750
-149
No files matched your search
@@ -0,0 +1,216 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
using MeWeb.Core.Net;
|
||||
|
||||
namespace MeWeb.Core.Router;
|
||||
|
||||
public static class Gateway
|
||||
{
|
||||
const int PortMappingPort = 5351;
|
||||
static readonly IPEndPoint Ssdp = new(IPAddress.Parse("239.255.255.250"), 1900);
|
||||
static readonly string[] SearchTargets = ["urn:schemas-upnp-org:device:InternetGatewayDevice:2", "urn:schemas-upnp-org:device:InternetGatewayDevice:1"];
|
||||
static readonly HttpClient Lan = new(new SocketsHttpHandler { UseProxy = false, AllowAutoRedirect = false, ConnectTimeout = TimeSpan.FromSeconds(2) })
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(4),
|
||||
};
|
||||
|
||||
public static async Task<RouterReport> Inspect(IPlatform platform, string gateway, CancellationToken ct)
|
||||
{
|
||||
var report = new RouterReport { Gateway = gateway };
|
||||
var address = Addresses.Parse(gateway);
|
||||
if (address is null || address.AddressFamily != AddressFamily.InterNetwork) return report;
|
||||
|
||||
var upnp = Upnp(platform, address, report, ct);
|
||||
var natPmp = NatPmp(address, report, ct);
|
||||
var pcp = Pcp(address, report, ct);
|
||||
await Task.WhenAll(upnp, natPmp, pcp);
|
||||
return report;
|
||||
}
|
||||
|
||||
static async Task Upnp(IPlatform platform, IPAddress gateway, RouterReport report, CancellationToken ct)
|
||||
{
|
||||
var location = await Discover(platform, gateway, ct);
|
||||
if (location is null) return;
|
||||
try
|
||||
{
|
||||
var description = XDocument.Parse(await Lan.GetStringAsync(location, ct));
|
||||
var root = description.Root?.Elements().FirstOrDefault(e => e.Name.LocalName == "device");
|
||||
if (root is null) return;
|
||||
report.Upnp = Answer.Answered;
|
||||
report.UpnpVersion = Text(root, "deviceType")?.EndsWith(":2", StringComparison.Ordinal) == true ? 2 : 1;
|
||||
report.Name = Text(root, "friendlyName");
|
||||
report.Make = Text(root, "manufacturer");
|
||||
report.Model = Text(root, "modelName");
|
||||
var service = root.Descendants().Where(e => e.Name.LocalName == "service")
|
||||
.Select(e => (Type: Text(e, "serviceType"), Control: Text(e, "controlURL")))
|
||||
.FirstOrDefault(s => s.Type is not null && (s.Type.Contains(":WANIPConnection:") || s.Type.Contains(":WANPPPConnection:")));
|
||||
if (service.Type is null || service.Control is null) return;
|
||||
var baseText = description.Root.Elements().FirstOrDefault(e => e.Name.LocalName == "URLBase")?.Value;
|
||||
var baseUri = Uri.TryCreate(baseText, UriKind.Absolute, out var parsed) && IsLocal(parsed) ? parsed : location;
|
||||
if (!Uri.TryCreate(baseUri, service.Control, out var control) || !IsLocal(control)) return;
|
||||
await ExternalAddress(control, service.Type, report, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or XmlException or TaskCanceledException && !ct.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
static async Task ExternalAddress(Uri control, string serviceType, RouterReport report, CancellationToken ct)
|
||||
{
|
||||
var body = $"""<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:GetExternalIPAddress xmlns:u="{serviceType}"/></s:Body></s:Envelope>""";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, control) { Content = new StringContent(body, Encoding.UTF8) };
|
||||
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("text/xml; charset=\"utf-8\"");
|
||||
request.Headers.TryAddWithoutValidation("SOAPAction", $"\"{serviceType}#GetExternalIPAddress\"");
|
||||
using var response = await Lan.SendAsync(request, ct);
|
||||
var answer = XDocument.Parse(await response.Content.ReadAsStringAsync(ct));
|
||||
var external = answer.Descendants().FirstOrDefault(e => e.Name.LocalName == "NewExternalIPAddress")?.Value?.Trim();
|
||||
if (Addresses.Parse(external) is not null)
|
||||
{
|
||||
report.WanAddress = external;
|
||||
return;
|
||||
}
|
||||
var code = answer.Descendants().FirstOrDefault(e => e.Name.LocalName == "errorCode")?.Value;
|
||||
report.UpnpError = int.TryParse(code, out var number) ? number : (int)response.StatusCode;
|
||||
report.Upnp = Answer.Refused;
|
||||
}
|
||||
|
||||
static async Task<Uri> Discover(IPlatform platform, IPAddress gateway, CancellationToken ct)
|
||||
{
|
||||
using var hold = platform.AllowMulticast();
|
||||
using var udp = new UdpClient(AddressFamily.InterNetwork);
|
||||
try
|
||||
{
|
||||
udp.Client.Bind(new IPEndPoint(IPAddress.Any, 0));
|
||||
foreach (var target in SearchTargets)
|
||||
{
|
||||
var search = Encoding.ASCII.GetBytes($"M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: \"ssdp:discover\"\r\nMX: 2\r\nST: {target}\r\n\r\n");
|
||||
await udp.SendAsync(search, Ssdp, ct);
|
||||
await udp.SendAsync(search, Ssdp, ct);
|
||||
}
|
||||
var answers = new List<(IPAddress From, Uri Location)>();
|
||||
using var window = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
window.CancelAfter(TimeSpan.FromSeconds(2.5));
|
||||
while (true)
|
||||
{
|
||||
UdpReceiveResult received;
|
||||
try
|
||||
{
|
||||
received = await udp.ReceiveAsync(window.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
var headers = ParseHeaders(Encoding.ASCII.GetString(received.Buffer));
|
||||
if (!headers.TryGetValue("LOCATION", out var text) || !Uri.TryCreate(text, UriKind.Absolute, out var location) || !IsLocal(location)) continue;
|
||||
answers.Add((received.RemoteEndPoint.Address, location));
|
||||
}
|
||||
if (answers.Count == 0) return null;
|
||||
return answers.OrderByDescending(a => a.From.Equals(gateway)).First().Location;
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task NatPmp(IPAddress gateway, RouterReport report, CancellationToken ct)
|
||||
{
|
||||
var answer = await Exchange(gateway, [0, 0], ct);
|
||||
if (answer is null) return;
|
||||
if (answer.Length < 12 || answer[0] != 0 || answer[1] != 128) return;
|
||||
report.NatPmpResult = (answer[2] << 8) | answer[3];
|
||||
if (report.NatPmpResult != 0)
|
||||
{
|
||||
report.NatPmp = Answer.Refused;
|
||||
return;
|
||||
}
|
||||
report.NatPmp = Answer.Answered;
|
||||
var external = new IPAddress(answer.AsSpan(8, 4));
|
||||
if (report.WanAddress is null && !external.Equals(IPAddress.Any)) report.WanAddress = external.ToString();
|
||||
}
|
||||
|
||||
static async Task Pcp(IPAddress gateway, RouterReport report, CancellationToken ct)
|
||||
{
|
||||
using var udp = new UdpClient(AddressFamily.InterNetwork);
|
||||
try
|
||||
{
|
||||
udp.Connect(gateway, PortMappingPort);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var local = ((IPEndPoint)udp.Client.LocalEndPoint)?.Address ?? IPAddress.Any;
|
||||
var request = new byte[24];
|
||||
request[0] = 2;
|
||||
local.MapToIPv6().GetAddressBytes().CopyTo(request, 8);
|
||||
var answer = await Exchange(udp, request, ct);
|
||||
if (answer is null || answer.Length < 24 || answer[0] != 2 || answer[1] != 0x80) return;
|
||||
report.PcpResult = answer[3];
|
||||
report.Pcp = answer[3] == 0 ? Answer.Answered : Answer.Refused;
|
||||
}
|
||||
|
||||
static async Task<byte[]> Exchange(IPAddress gateway, byte[] request, CancellationToken ct)
|
||||
{
|
||||
using var udp = new UdpClient(AddressFamily.InterNetwork);
|
||||
try
|
||||
{
|
||||
udp.Connect(gateway, PortMappingPort);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return await Exchange(udp, request, ct);
|
||||
}
|
||||
|
||||
static async Task<byte[]> Exchange(UdpClient udp, byte[] request, CancellationToken ct)
|
||||
{
|
||||
foreach (var wait in new[] { 1000, 2000 })
|
||||
{
|
||||
try
|
||||
{
|
||||
await udp.SendAsync(request, ct);
|
||||
using var window = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
window.CancelAfter(wait);
|
||||
var received = await udp.ReceiveAsync(window.Token);
|
||||
return received.Buffer;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Dictionary<string, string> ParseHeaders(string message)
|
||||
{
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var line in message.Split("\r\n"))
|
||||
{
|
||||
var colon = line.IndexOf(':');
|
||||
if (colon > 0) headers[line[..colon].Trim()] = line[(colon + 1)..].Trim();
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
static bool IsLocal(Uri uri) =>
|
||||
uri.Scheme == Uri.UriSchemeHttp && IPAddress.TryParse(uri.Host, out var host) &&
|
||||
Addresses.Classify(host) is AddressKind.Private or AddressKind.Shared or AddressKind.LinkLocal or AddressKind.UniqueLocal;
|
||||
|
||||
static string Text(XElement parent, string name)
|
||||
{
|
||||
var value = parent.Elements().FirstOrDefault(e => e.Name.LocalName == name)?.Value?.Trim();
|
||||
return string.IsNullOrEmpty(value) ? null : value;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user