Build / Desktop, server and tools (push) Successful in 1m8s
The server no longer answers 409, 413 or 429: a busy probe, a share or upload that is too large and a request over the limit now answer 400 with the same reason as before, and still say when to try again. The app reads the reason, so nothing changes for it; versions up to 0.15.4 report a limited speed test as unmeasured until they update. The app's commands can now be cancelled all the way down, and the house rules are written into PATTERNS.md and checked on every build, Android included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4YtEJrgxG12Pc39Fai8Bd
229 lines
9.0 KiB
C#
229 lines
9.0 KiB
C#
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;
|
|
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 = $"""<?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>""";
|
|
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<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)
|
|
{
|
|
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<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);
|
|
}
|
|
|
|
public 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;
|
|
}
|
|
}
|