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 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; report.UpnpControl = control.ToString(); report.UpnpService = service.Type; await ExternalAddress(control, service.Type, report, ct); } catch (Exception) when (!ct.IsCancellationRequested) { } } public static async Task<(XDocument Answer, int Status)> Soap(Uri control, string serviceType, string action, string arguments, CancellationToken ct) { var body = $"""{arguments}"""; 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}#{action}\""); using var response = await Lan.SendAsync(request, ct); 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); var external = answer.Descendants().FirstOrDefault(e => e.Name.LocalName == "NewExternalIPAddress")?.Value?.Trim(); if (Addresses.Parse(external) is not null) { report.WanAddress = external; return; } report.UpnpError = ErrorCode(answer, status); report.Upnp = Answer.Refused; } static async Task 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) { var received = default(UdpReceiveResult); 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; } public static async Task 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); } public static async Task 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 ParseHeaders(string message) { var headers = new Dictionary(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; } }