2026-10-15 04:39:03 +02:00
|
|
|
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;
|
2026-10-15 05:41:44 +02:00
|
|
|
report.UpnpControl = control.ToString();
|
|
|
|
|
report.UpnpService = service.Type;
|
2026-10-15 04:39:03 +02:00
|
|
|
await ExternalAddress(control, service.Type, report, ct);
|
|
|
|
|
}
|
2026-10-15 09:10:37 +02:00
|
|
|
catch (Exception) when (!ct.IsCancellationRequested)
|
2026-10-15 04:39:03 +02:00
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 05:41:44 +02:00
|
|
|
public static async Task<(XDocument Answer, int Status)> Soap(Uri control, string serviceType, string action, string arguments, CancellationToken ct)
|
2026-10-15 04:39:03 +02:00
|
|
|
{
|
2026-10-15 05:41:44 +02:00
|
|
|
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:{action} xmlns:u="{serviceType}">{arguments}</u:{action}></s:Body></s:Envelope>""";
|
2026-10-15 04:39:03 +02:00
|
|
|
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\"");
|
2026-10-15 05:41:44 +02:00
|
|
|
request.Headers.TryAddWithoutValidation("SOAPAction", $"\"{serviceType}#{action}\"");
|
2026-10-15 04:39:03 +02:00
|
|
|
using var response = await Lan.SendAsync(request, ct);
|
2026-10-15 05:41:44 +02:00
|
|
|
return (XDocument.Parse(await response.Content.ReadAsStringAsync(ct)), (int)response.StatusCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static int ErrorCode(XDocument answer, int status)
|
|
|
|
|
{
|
|
|
|
|
var code = answer.Descendants().FirstOrDefault(e => e.Name.LocalName == "errorCode")?.Value;
|
|
|
|
|
return int.TryParse(code, out var number) ? number : status;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static async Task ExternalAddress(Uri control, string serviceType, RouterReport report, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
var (answer, status) = await Soap(control, serviceType, "GetExternalIPAddress", "", ct);
|
2026-10-15 04:39:03 +02:00
|
|
|
var external = answer.Descendants().FirstOrDefault(e => e.Name.LocalName == "NewExternalIPAddress")?.Value?.Trim();
|
|
|
|
|
if (Addresses.Parse(external) is not null)
|
|
|
|
|
{
|
|
|
|
|
report.WanAddress = external;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-15 05:41:44 +02:00
|
|
|
report.UpnpError = ErrorCode(answer, status);
|
2026-10-15 04:39:03 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 05:41:44 +02:00
|
|
|
public static async Task<byte[]> Exchange(IPAddress gateway, byte[] request, CancellationToken ct)
|
2026-10-15 04:39:03 +02:00
|
|
|
{
|
|
|
|
|
using var udp = new UdpClient(AddressFamily.InterNetwork);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
udp.Connect(gateway, PortMappingPort);
|
|
|
|
|
}
|
|
|
|
|
catch (SocketException)
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return await Exchange(udp, request, ct);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 05:41:44 +02:00
|
|
|
public static async Task<byte[]> Exchange(UdpClient udp, byte[] request, CancellationToken ct)
|
2026-10-15 04:39:03 +02:00
|
|
|
{
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|