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,126 @@
|
||||
using MeWeb.Core.History;
|
||||
using MeWeb.Core.Knowledge;
|
||||
using MeWeb.Core.Net;
|
||||
using MeWeb.Core.Router;
|
||||
|
||||
namespace MeWeb.Core.Checks;
|
||||
|
||||
public class RunReport
|
||||
{
|
||||
public DateTimeOffset StartedAt { get; set; }
|
||||
public DeviceKind Device { get; set; }
|
||||
public OnlineReport Online { get; set; }
|
||||
public LocalSnapshot Local { get; set; }
|
||||
public string PublicV4 { get; set; }
|
||||
public string PublicV6 { get; set; }
|
||||
public AsnEntry NetworkV4 { get; set; }
|
||||
public AsnEntry NetworkV6 { get; set; }
|
||||
public ProviderProfile Provider { get; set; }
|
||||
public RouterReport Router { get; set; }
|
||||
public List<Hop> Hops { get; set; } = [];
|
||||
public NatKind Nat { get; set; }
|
||||
public HistorySummary History { get; set; }
|
||||
public List<Finding> Findings { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CheckRunner(IPlatform platform, ServerProbe server, KnowledgeBase knowledge, HistoryStore history)
|
||||
{
|
||||
public async Task<RunReport> Run(Action<Finding, RunReport> report, CancellationToken ct)
|
||||
{
|
||||
var run = new RunReport { StartedAt = DateTimeOffset.UtcNow, Device = platform.Device };
|
||||
foreach (var id in Enum.GetValues<CheckId>()) report(Finding.Running(id), run);
|
||||
|
||||
void Settle(Finding finding)
|
||||
{
|
||||
lock (run.Findings) run.Findings.Add(finding);
|
||||
report(finding, run);
|
||||
}
|
||||
|
||||
Task Step(CheckId id, Func<Task> body) => Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await body();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Settle(new Finding { Id = id, Outcome = Outcome.Unknown, Summary = "CheckFailed", Args = [ex.GetType().Name] });
|
||||
}
|
||||
}, ct);
|
||||
|
||||
var local = Task.Run(platform.ReadNetwork, ct);
|
||||
var online = server.Online(ct);
|
||||
var v4 = server.Echo(false, ct);
|
||||
var v6 = server.Echo(true, ct);
|
||||
var route = Task.Run(async () =>
|
||||
{
|
||||
var target = await ServerProbe.ResolveV4(ct);
|
||||
return target is null ? [] : await Route.Trace(target, ct);
|
||||
}, ct);
|
||||
|
||||
var connection = Step(CheckId.Connection, async () =>
|
||||
{
|
||||
run.Local = await local;
|
||||
Settle(Verdicts.Connection(run.Local));
|
||||
});
|
||||
|
||||
var onlineDone = Step(CheckId.Online, async () =>
|
||||
{
|
||||
run.Online = await online;
|
||||
await connection;
|
||||
Settle(Verdicts.Online(run.Online, run.Local));
|
||||
});
|
||||
|
||||
var addresses = Step(CheckId.Addresses, async () =>
|
||||
{
|
||||
run.PublicV4 = await v4;
|
||||
run.PublicV6 = await v6;
|
||||
await connection;
|
||||
Settle(Verdicts.Addresses(run.PublicV4, run.PublicV6, run.Local));
|
||||
});
|
||||
|
||||
var provider = Step(CheckId.Provider, async () =>
|
||||
{
|
||||
await addresses;
|
||||
var table = knowledge.Asn;
|
||||
run.NetworkV4 = table?.Find(Addresses.Parse(run.PublicV4));
|
||||
run.NetworkV6 = table?.Find(Addresses.Parse(run.PublicV6));
|
||||
run.Provider = knowledge.ProviderFor((run.NetworkV4 ?? run.NetworkV6)?.Number ?? 0);
|
||||
Settle(Verdicts.Provider(knowledge, run.NetworkV4, run.NetworkV6, run.PublicV4 is not null || run.PublicV6 is not null));
|
||||
});
|
||||
|
||||
var router = Step(CheckId.Router, async () =>
|
||||
{
|
||||
await connection;
|
||||
run.Router = await Gateway.Inspect(platform, run.Local.GatewayV4, ct);
|
||||
Settle(Verdicts.Router(run.Router, run.Local));
|
||||
});
|
||||
|
||||
var nat = Step(CheckId.Nat, async () =>
|
||||
{
|
||||
await Task.WhenAll(router, addresses);
|
||||
run.Hops = await route;
|
||||
var (finding, kind) = Verdicts.Nat(run.Local, run.Router, run.PublicV4, run.PublicV6, run.Hops);
|
||||
run.Nat = kind;
|
||||
Settle(finding);
|
||||
});
|
||||
|
||||
await Step(CheckId.History, async () =>
|
||||
{
|
||||
await Task.WhenAll(onlineDone, provider, nat);
|
||||
var number = (run.NetworkV4 ?? run.NetworkV6)?.Number ?? 0;
|
||||
var network = $"{number}/{run.Local?.GatewayV4 ?? run.Local?.Active?.Kind.ToString() ?? "none"}";
|
||||
run.History = history.Append(new HistoryEntry
|
||||
{
|
||||
At = run.StartedAt,
|
||||
Network = network,
|
||||
PublicV4 = run.PublicV4,
|
||||
PublicV6 = run.PublicV6,
|
||||
Asn = number,
|
||||
Nat = run.Nat.ToString(),
|
||||
});
|
||||
Settle(Verdicts.History(run.History));
|
||||
});
|
||||
return run;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace MeWeb.Core.Checks;
|
||||
|
||||
public enum CheckId
|
||||
{
|
||||
Online,
|
||||
Connection,
|
||||
Addresses,
|
||||
Provider,
|
||||
Router,
|
||||
Nat,
|
||||
History,
|
||||
}
|
||||
|
||||
public enum Outcome
|
||||
{
|
||||
Waiting,
|
||||
Running,
|
||||
Good,
|
||||
Caution,
|
||||
Bad,
|
||||
Unknown,
|
||||
Info,
|
||||
}
|
||||
|
||||
public enum NatKind
|
||||
{
|
||||
Unknown,
|
||||
Direct,
|
||||
HomeRouter,
|
||||
DoubleNat,
|
||||
Shared,
|
||||
Translated,
|
||||
NoIpv4,
|
||||
}
|
||||
|
||||
public class Fact
|
||||
{
|
||||
public string Label { get; set; }
|
||||
public string Text { get; set; }
|
||||
public string Key { get; set; }
|
||||
public List<string> Args { get; set; } = [];
|
||||
public decimal? Amount { get; set; }
|
||||
public string Currency { get; set; }
|
||||
public string Link { get; set; }
|
||||
public string Basis { get; set; }
|
||||
public string Date { get; set; }
|
||||
}
|
||||
|
||||
public class Finding
|
||||
{
|
||||
public CheckId Id { get; set; }
|
||||
public Outcome Outcome { get; set; }
|
||||
public string Summary { get; set; }
|
||||
public List<string> Args { get; set; } = [];
|
||||
public List<Fact> Facts { get; set; } = [];
|
||||
|
||||
public static Finding Running(CheckId id) => new() { Id = id, Outcome = Outcome.Running };
|
||||
|
||||
public Finding With(string label, string text)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(text)) Facts.Add(new Fact { Label = label, Text = text });
|
||||
return this;
|
||||
}
|
||||
|
||||
public Finding WithKey(string label, string key, params string[] args)
|
||||
{
|
||||
Facts.Add(new Fact { Label = label, Key = key, Args = [.. args] });
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Net.Sockets;
|
||||
|
||||
using MeWeb.Shared;
|
||||
|
||||
namespace MeWeb.Core.Checks;
|
||||
|
||||
public enum OnlineState
|
||||
{
|
||||
Online,
|
||||
Captive,
|
||||
Rewritten,
|
||||
NoNames,
|
||||
Offline,
|
||||
}
|
||||
|
||||
public class OnlineReport
|
||||
{
|
||||
public OnlineState State { get; set; }
|
||||
public ViewInfo Server { get; set; }
|
||||
public string Portal { get; set; }
|
||||
}
|
||||
|
||||
public class ServerProbe(HttpClient https, HttpClient plain)
|
||||
{
|
||||
static readonly TimeSpan Patience = TimeSpan.FromSeconds(6);
|
||||
|
||||
public async Task<OnlineReport> Online(CancellationToken ct)
|
||||
{
|
||||
var names = Resolves(Endpoints.Host, ct);
|
||||
var info = Info(ct);
|
||||
var web = Plain(ct);
|
||||
await Task.WhenAll(names, info, web);
|
||||
var (plainClean, portal) = web.Result;
|
||||
|
||||
if (info.Result is not null)
|
||||
return new OnlineReport { State = plainClean is false ? OnlineState.Rewritten : OnlineState.Online, Server = info.Result, Portal = portal };
|
||||
if (plainClean is false) return new OnlineReport { State = OnlineState.Captive, Portal = portal };
|
||||
return new OnlineReport { State = names.Result ? OnlineState.Offline : OnlineState.NoNames };
|
||||
}
|
||||
|
||||
public async Task<string> Echo(bool v6, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(Patience);
|
||||
var origin = v6 ? Endpoints.BaseV6 : Endpoints.BaseV4;
|
||||
var answer = await https.GetFromJsonAsync(origin + Endpoints.Server.GET_Ip, MeWebJsonContext.Default.ViewIp, timeout.Token);
|
||||
return answer?.Address;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or System.Text.Json.JsonException && !ct.IsCancellationRequested)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<IPAddress> ResolveV4(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var addresses = await Dns.GetHostAddressesAsync(Endpoints.HostV4, AddressFamily.InterNetwork, ct);
|
||||
return addresses.FirstOrDefault();
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<bool> Resolves(string host, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (await Dns.GetHostAddressesAsync(host, ct)).Length > 0;
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async Task<ViewInfo> Info(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(Patience);
|
||||
return await https.GetFromJsonAsync(Endpoints.Base + Endpoints.Server.GET_Info, MeWebJsonContext.Default.ViewInfo, timeout.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or System.Text.Json.JsonException && !ct.IsCancellationRequested)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async Task<(bool? Clean, string Portal)> Plain(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(Patience);
|
||||
using var response = await plain.GetAsync($"http://{Endpoints.Host}{Endpoints.Server.GET_Info}", HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
||||
var location = response.Headers.Location;
|
||||
if ((int)response.StatusCode is >= 300 and < 400 && location is not null)
|
||||
{
|
||||
var target = location.IsAbsoluteUri ? location : new Uri(new Uri($"http://{Endpoints.Host}"), location);
|
||||
var clean = target.Scheme == Uri.UriSchemeHttps && string.Equals(target.Host, Endpoints.Host, StringComparison.OrdinalIgnoreCase);
|
||||
return (clean, clean ? null : target.Host);
|
||||
}
|
||||
if (response.IsSuccessStatusCode && response.Content.Headers.ContentType?.MediaType == "application/json") return (true, null);
|
||||
return (false, null);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using System.Globalization;
|
||||
|
||||
using MeWeb.Core.History;
|
||||
using MeWeb.Core.Knowledge;
|
||||
using MeWeb.Core.Net;
|
||||
using MeWeb.Core.Router;
|
||||
|
||||
namespace MeWeb.Core.Checks;
|
||||
|
||||
public static class Verdicts
|
||||
{
|
||||
public static Finding Online(OnlineReport report, LocalSnapshot local)
|
||||
{
|
||||
var finding = new Finding { Id = CheckId.Online };
|
||||
switch (report.State)
|
||||
{
|
||||
case OnlineState.Online:
|
||||
finding.Outcome = Outcome.Good;
|
||||
finding.Summary = "OnlineGood";
|
||||
break;
|
||||
case OnlineState.Rewritten:
|
||||
finding.Outcome = Outcome.Caution;
|
||||
finding.Summary = "OnlineRewritten";
|
||||
finding.With("FactRedirectedTo", report.Portal);
|
||||
break;
|
||||
case OnlineState.Captive:
|
||||
finding.Outcome = Outcome.Caution;
|
||||
finding.Summary = "OnlineCaptive";
|
||||
finding.With("FactRedirectedTo", report.Portal);
|
||||
break;
|
||||
case OnlineState.NoNames when local.Active is not null:
|
||||
finding.Outcome = Outcome.Bad;
|
||||
finding.Summary = "OnlineNoNames";
|
||||
break;
|
||||
default:
|
||||
finding.Outcome = Outcome.Bad;
|
||||
finding.Summary = local.Active is null ? "OnlineNoNetwork" : "OnlineOffline";
|
||||
break;
|
||||
}
|
||||
return finding;
|
||||
}
|
||||
|
||||
public static Finding Connection(LocalSnapshot local)
|
||||
{
|
||||
var active = local.Active;
|
||||
if (active is null) return new Finding { Id = CheckId.Connection, Outcome = Outcome.Bad, Summary = "ConnectionNone" };
|
||||
var finding = new Finding
|
||||
{
|
||||
Id = CheckId.Connection,
|
||||
Outcome = Outcome.Info,
|
||||
Summary = active.Kind switch
|
||||
{
|
||||
LinkKind.Wifi => "ConnectionWifi",
|
||||
LinkKind.Cable => "ConnectionCable",
|
||||
LinkKind.Mobile => "ConnectionMobile",
|
||||
_ => "ConnectionOther",
|
||||
},
|
||||
};
|
||||
finding.With("FactAdapter", active.Name);
|
||||
if (active.SpeedMbps > 0) finding.WithKey("FactLinkSpeed", "ValueMbps", active.SpeedMbps.ToString(CultureInfo.InvariantCulture));
|
||||
finding.With("FactLocalAddress", local.LocalV4);
|
||||
var v6 = local.GlobalV6;
|
||||
if (v6.Count > 0) finding.With("FactLocalIpv6", string.Join("\n", v6));
|
||||
finding.With("FactGateway", local.GatewayV4 ?? active.Gateways.FirstOrDefault());
|
||||
if (local.Dns.Count > 0) finding.With("FactDns", string.Join(", ", local.Dns.Take(3)));
|
||||
return finding;
|
||||
}
|
||||
|
||||
public static Finding Addresses(string v4, string v6, LocalSnapshot local)
|
||||
{
|
||||
var finding = new Finding { Id = CheckId.Addresses };
|
||||
(finding.Outcome, finding.Summary) = (v4, v6) switch
|
||||
{
|
||||
(not null, not null) => (Outcome.Good, "AddressesBoth"),
|
||||
(not null, null) => (Outcome.Caution, "AddressesV4Only"),
|
||||
(null, not null) => (Outcome.Caution, "AddressesV6Only"),
|
||||
_ => (Outcome.Unknown, "AddressesNone"),
|
||||
};
|
||||
if (v4 is not null)
|
||||
{
|
||||
finding.With("FactPublicV4", v4);
|
||||
var own = local.Active?.Addresses.Any(a => Net.Addresses.SameAddress(a, v4)) == true;
|
||||
finding.WithKey("FactWhoHasIt", own ? "ValueOnThisDevice" : "ValueSharedAtHome");
|
||||
}
|
||||
if (v6 is not null)
|
||||
{
|
||||
finding.With("FactPublicV6", v6);
|
||||
var own = local.Active?.Addresses.Any(a => Net.Addresses.SameAddress(a, v6)) == true;
|
||||
finding.WithKey("FactWhoHasIpv6", own ? "ValueOnThisDevice" : "ValueIpv6NotThisDevice");
|
||||
}
|
||||
return finding;
|
||||
}
|
||||
|
||||
public static Finding Provider(KnowledgeBase knowledge, AsnEntry v4, AsnEntry v6, bool anyAddress)
|
||||
{
|
||||
var finding = new Finding { Id = CheckId.Provider };
|
||||
if (!anyAddress)
|
||||
{
|
||||
finding.Outcome = Outcome.Unknown;
|
||||
finding.Summary = "ProviderNoAddress";
|
||||
return finding;
|
||||
}
|
||||
if (!knowledge.HasAsnTable)
|
||||
{
|
||||
finding.Outcome = Outcome.Unknown;
|
||||
finding.Summary = "ProviderNoMap";
|
||||
return finding;
|
||||
}
|
||||
var main = v4 ?? v6;
|
||||
if (main is null)
|
||||
{
|
||||
finding.Outcome = Outcome.Unknown;
|
||||
finding.Summary = "ProviderNotFound";
|
||||
return finding;
|
||||
}
|
||||
var profile = knowledge.ProviderFor(main.Number);
|
||||
finding.Outcome = Outcome.Info;
|
||||
finding.Summary = profile is null ? "ProviderUnlisted" : "ProviderListed";
|
||||
finding.Args = [profile?.Name ?? Display(main)];
|
||||
finding.With("FactNetwork", Describe(main));
|
||||
if (v6 is not null && v4 is not null && v6.Number != v4.Number) finding.With("FactNetworkIpv6", Describe(v6));
|
||||
if (profile is null) return finding;
|
||||
AddFact(finding, "FactProviderIpv4", "ProviderIpv4", profile.Ipv4);
|
||||
AddFact(finding, "FactProviderPublic", "ProviderPublic", profile.PublicAddress);
|
||||
AddFact(finding, "FactProviderIpv6", "ProviderIpv6", profile.Ipv6);
|
||||
AddFact(finding, "FactProviderPort25", "ProviderPort25", profile.Port25);
|
||||
if (profile.Sheet is not null) finding.Facts.Add(new Fact { Label = "FactProviderSheet", Key = "ValueOpenSheet", Link = profile.Sheet });
|
||||
return finding;
|
||||
}
|
||||
|
||||
public static Finding Router(RouterReport router, LocalSnapshot local)
|
||||
{
|
||||
var finding = new Finding { Id = CheckId.Router };
|
||||
if (router.Gateway is null)
|
||||
{
|
||||
finding.Outcome = Outcome.Info;
|
||||
finding.Summary = local.Active?.Kind == LinkKind.Mobile ? "RouterMobile" : "RouterNone";
|
||||
return finding;
|
||||
}
|
||||
(finding.Outcome, finding.Summary) = router switch
|
||||
{
|
||||
{ AnyMapping: true } => (Outcome.Good, "RouterOpens"),
|
||||
{ AnyAnswer: true } => (Outcome.Caution, "RouterRefuses"),
|
||||
_ => (Outcome.Caution, "RouterSilent"),
|
||||
};
|
||||
finding.With("FactRouter", Join(router.Make, router.Label));
|
||||
finding.With("FactGateway", router.Gateway);
|
||||
finding.WithKey("FactUpnp", AnswerKey(router.Upnp), router.Upnp == Answer.Refused ? router.UpnpError.ToString(CultureInfo.InvariantCulture) : router.UpnpVersion.ToString(CultureInfo.InvariantCulture));
|
||||
finding.WithKey("FactNatPmp", AnswerKey(router.NatPmp), router.NatPmpResult.ToString(CultureInfo.InvariantCulture));
|
||||
finding.WithKey("FactPcp", AnswerKey(router.Pcp), router.PcpResult.ToString(CultureInfo.InvariantCulture));
|
||||
finding.With("FactRouterWan", router.WanAddress);
|
||||
return finding;
|
||||
}
|
||||
|
||||
public static (Finding Finding, NatKind Kind) Nat(LocalSnapshot local, RouterReport router, string v4, string v6, List<Hop> hops)
|
||||
{
|
||||
var kind = NatOf(local, router, v4);
|
||||
var finding = new Finding { Id = CheckId.Nat };
|
||||
(finding.Outcome, finding.Summary) = kind switch
|
||||
{
|
||||
NatKind.Direct => (Outcome.Good, "NatDirect"),
|
||||
NatKind.HomeRouter => (Outcome.Good, "NatHomeRouter"),
|
||||
NatKind.DoubleNat => (Outcome.Caution, "NatDouble"),
|
||||
NatKind.Shared => (Outcome.Bad, "NatShared"),
|
||||
NatKind.Translated => (Outcome.Bad, "NatTranslated"),
|
||||
NatKind.NoIpv4 => (Outcome.Info, "NatNoIpv4"),
|
||||
_ => (Outcome.Unknown, "NatUnknown"),
|
||||
};
|
||||
finding.With("FactRouterWan", router?.WanAddress);
|
||||
finding.With("FactPublicV4", v4);
|
||||
var before = Route.BeforeInternet(hops);
|
||||
if (hops.Count > 0) finding.With("FactRoute", string.Join(" → ", before.Select(h => h.Address ?? "*").Append(hops.FirstOrDefault(h => h.Kind == AddressKind.Public)?.Address ?? "…")));
|
||||
if (kind == NatKind.Unknown && before.Any(h => h.Kind == AddressKind.Shared)) finding.WithKey("FactHint", "HintSharedHop");
|
||||
|
||||
var v6Direct = v6 is not null && local.Active?.Addresses.Any(a => Net.Addresses.SameAddress(a, v6)) == true;
|
||||
if (v6 is not null) finding.WithKey("FactIpv6", v6Direct ? "ValueIpv6Direct" : "ValueIpv6Translated");
|
||||
if (v6Direct && finding.Outcome is Outcome.Bad or Outcome.Unknown && kind != NatKind.NoIpv4)
|
||||
{
|
||||
finding.Outcome = Outcome.Caution;
|
||||
finding.WithKey("FactWayOut", "ValueIpv6Way");
|
||||
}
|
||||
return (finding, kind);
|
||||
}
|
||||
|
||||
public static NatKind NatOf(LocalSnapshot local, RouterReport router, string v4)
|
||||
{
|
||||
if (v4 is null) return NatKind.NoIpv4;
|
||||
if (local.Active?.Addresses.Any(a => Net.Addresses.SameAddress(a, v4)) == true) return NatKind.Direct;
|
||||
var localV4 = Net.Addresses.Parse(local.LocalV4);
|
||||
if (router?.Gateway is null && localV4 is not null && Net.Addresses.Classify(localV4) is AddressKind.Shared or AddressKind.Private && local.Active?.Kind == LinkKind.Mobile)
|
||||
return NatKind.Shared;
|
||||
var wan = Net.Addresses.Parse(router?.WanAddress);
|
||||
if (wan is null) return NatKind.Unknown;
|
||||
if (Net.Addresses.SameAddress(router.WanAddress, v4)) return NatKind.HomeRouter;
|
||||
return Net.Addresses.Classify(wan) switch
|
||||
{
|
||||
AddressKind.Shared => NatKind.Shared,
|
||||
AddressKind.Private => NatKind.DoubleNat,
|
||||
AddressKind.Public => NatKind.Translated,
|
||||
_ => NatKind.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
public static Finding History(HistorySummary summary)
|
||||
{
|
||||
var finding = new Finding { Id = CheckId.History, Outcome = Outcome.Info };
|
||||
var since = summary.Since.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
if (summary.Checks <= 1)
|
||||
{
|
||||
finding.Summary = "HistoryFirst";
|
||||
return finding;
|
||||
}
|
||||
finding.Summary = summary.Changes == 0 ? "HistoryStable" : "HistoryChanged";
|
||||
finding.Args = [summary.Checks.ToString(CultureInfo.InvariantCulture), since, summary.Changes.ToString(CultureInfo.InvariantCulture)];
|
||||
finding.WithKey("FactChecks", "ValueCount", summary.Checks.ToString(CultureInfo.InvariantCulture));
|
||||
finding.Facts.Add(new Fact { Label = "FactSince", Date = since });
|
||||
finding.WithKey("FactAddressesSeen", "ValueCount", summary.AddressesSeen.ToString(CultureInfo.InvariantCulture));
|
||||
return finding;
|
||||
}
|
||||
|
||||
static void AddFact(Finding finding, string label, string prefix, ProviderFact fact)
|
||||
{
|
||||
if (fact?.Value is null) return;
|
||||
finding.Facts.Add(new Fact
|
||||
{
|
||||
Label = label,
|
||||
Key = prefix + Pascal(fact.Value),
|
||||
Amount = fact.Price,
|
||||
Currency = fact.Currency,
|
||||
Link = fact.Source,
|
||||
Basis = fact.Basis is null ? null : "Basis" + Pascal(fact.Basis),
|
||||
Date = fact.Date,
|
||||
});
|
||||
}
|
||||
|
||||
static string AnswerKey(Answer answer) => answer switch
|
||||
{
|
||||
Answer.Answered => "ValueAnswered",
|
||||
Answer.Refused => "ValueRefused",
|
||||
_ => "ValueSilent",
|
||||
};
|
||||
|
||||
static string Display(AsnEntry entry) => string.IsNullOrWhiteSpace(entry.Name) ? $"AS{entry.Number}" : entry.Name;
|
||||
|
||||
static string Describe(AsnEntry entry) => $"{Display(entry)} (AS{entry.Number}{(string.IsNullOrEmpty(entry.Country) || entry.Country == "None" ? "" : ", " + entry.Country)})";
|
||||
|
||||
static string Join(string make, string label)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(make)) return label;
|
||||
if (string.IsNullOrWhiteSpace(label) || label.Contains(make.Split(' ')[0], StringComparison.OrdinalIgnoreCase)) return label ?? make;
|
||||
return $"{make} {label}";
|
||||
}
|
||||
|
||||
static string Pascal(string value) => string.Concat(value.Split('-').Select(p => p.Length == 0 ? p : char.ToUpperInvariant(p[0]) + p[1..]));
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MeWeb.Core.History;
|
||||
|
||||
public class HistoryEntry
|
||||
{
|
||||
public DateTimeOffset At { get; set; }
|
||||
public string Network { get; set; }
|
||||
public string PublicV4 { get; set; }
|
||||
public string PublicV6 { get; set; }
|
||||
public uint Asn { get; set; }
|
||||
public string Nat { get; set; }
|
||||
}
|
||||
|
||||
public class HistorySummary
|
||||
{
|
||||
public int Checks { get; set; }
|
||||
public DateTimeOffset Since { get; set; }
|
||||
public int AddressesSeen { get; set; }
|
||||
public int Changes { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, WriteIndented = true)]
|
||||
[JsonSerializable(typeof(List<HistoryEntry>))]
|
||||
public partial class HistoryJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
public class HistoryStore(string folder)
|
||||
{
|
||||
const int Keep = 500;
|
||||
readonly Lock gate = new();
|
||||
|
||||
string FilePath => Path.Combine(folder, "history.json");
|
||||
|
||||
public List<HistoryEntry> Read()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(FilePath)) return [];
|
||||
using var stream = File.OpenRead(FilePath);
|
||||
return JsonSerializer.Deserialize(stream, HistoryJsonContext.Default.ListHistoryEntry) ?? [];
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public HistorySummary Append(HistoryEntry entry)
|
||||
{
|
||||
var entries = Read();
|
||||
entries.Add(entry);
|
||||
if (entries.Count > Keep) entries = entries[^Keep..];
|
||||
lock (gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
var temporary = FilePath + ".tmp";
|
||||
using (var stream = File.Create(temporary))
|
||||
JsonSerializer.Serialize(stream, entries, HistoryJsonContext.Default.ListHistoryEntry);
|
||||
File.Move(temporary, FilePath, overwrite: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return Summarise(entries, entry.Network);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(FilePath);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static HistorySummary Summarise(IEnumerable<HistoryEntry> entries, string network)
|
||||
{
|
||||
var same = entries.Where(e => e.Network == network).OrderBy(e => e.At).ToList();
|
||||
var withAddress = same.Where(e => e.PublicV4 is not null).ToList();
|
||||
var changes = withAddress.Zip(withAddress.Skip(1)).Count(p => p.First.PublicV4 != p.Second.PublicV4);
|
||||
return new HistorySummary
|
||||
{
|
||||
Checks = same.Count,
|
||||
Since = same.FirstOrDefault()?.At ?? DateTimeOffset.UtcNow,
|
||||
AddressesSeen = withAddress.Select(e => e.PublicV4).Distinct().Count(),
|
||||
Changes = changes,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace MeWeb.Core.Knowledge;
|
||||
|
||||
public class AsnEntry
|
||||
{
|
||||
public uint Number { get; set; }
|
||||
public string Country { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
|
||||
public class AsnRange
|
||||
{
|
||||
public IPAddress Start { get; set; }
|
||||
public IPAddress End { get; set; }
|
||||
public uint Number { get; set; }
|
||||
public string Country { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AsnTable
|
||||
{
|
||||
const uint Magic = 0x314B574D;
|
||||
|
||||
AsnEntry[] systems;
|
||||
uint[] v4Start;
|
||||
int[] v4System;
|
||||
UInt128[] v6Start;
|
||||
int[] v6System;
|
||||
|
||||
public DateTimeOffset SourceDate { get; private set; }
|
||||
|
||||
public int Count => systems.Length;
|
||||
|
||||
public AsnEntry Find(IPAddress address)
|
||||
{
|
||||
if (address is null) return null;
|
||||
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
|
||||
var index = address.AddressFamily == AddressFamily.InterNetwork
|
||||
? Search(v4Start, BinaryPrimitives.ReadUInt32BigEndian(address.GetAddressBytes()), v4System)
|
||||
: Search(v6Start, BinaryPrimitives.ReadUInt128BigEndian(address.GetAddressBytes()), v6System);
|
||||
return index < 0 ? null : systems[index];
|
||||
}
|
||||
|
||||
public static AsnTable Read(Stream compressed)
|
||||
{
|
||||
using var brotli = new BrotliStream(compressed, CompressionMode.Decompress);
|
||||
using var reader = new BinaryReader(brotli, Encoding.UTF8);
|
||||
if (reader.ReadUInt32() != Magic) throw new InvalidDataException("not a me-web? provider table");
|
||||
var table = new AsnTable { SourceDate = DateTimeOffset.FromUnixTimeSeconds(reader.ReadInt64()) };
|
||||
table.systems = new AsnEntry[reader.Read7BitEncodedInt()];
|
||||
for (var i = 0; i < table.systems.Length; i++)
|
||||
table.systems[i] = new AsnEntry { Number = (uint)reader.Read7BitEncodedInt64(), Country = reader.ReadString(), Name = reader.ReadString() };
|
||||
|
||||
var count = reader.Read7BitEncodedInt();
|
||||
table.v4Start = new uint[count];
|
||||
table.v4System = new int[count];
|
||||
var previous4 = 0u;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
previous4 += (uint)reader.Read7BitEncodedInt64();
|
||||
table.v4Start[i] = previous4;
|
||||
table.v4System[i] = reader.Read7BitEncodedInt() - 1;
|
||||
}
|
||||
|
||||
count = reader.Read7BitEncodedInt();
|
||||
table.v6Start = new UInt128[count];
|
||||
table.v6System = new int[count];
|
||||
var previous6 = UInt128.Zero;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
previous6 += ReadBig(reader);
|
||||
table.v6Start[i] = previous6;
|
||||
table.v6System[i] = reader.Read7BitEncodedInt() - 1;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
public static void Write(Stream output, DateTimeOffset sourceDate, IEnumerable<AsnRange> ranges)
|
||||
{
|
||||
var list = ranges.Where(r => r.Number != 0).ToList();
|
||||
var systems = list.GroupBy(r => r.Number).OrderBy(g => g.Key)
|
||||
.Select(g => new AsnEntry { Number = g.Key, Country = g.First().Country ?? "", Name = g.First().Name ?? "" }).ToList();
|
||||
var index = systems.Select((s, i) => (s.Number, i)).ToDictionary(p => p.Number, p => p.i);
|
||||
|
||||
using var brotli = new BrotliStream(output, CompressionLevel.SmallestSize, leaveOpen: true);
|
||||
using var writer = new BinaryWriter(brotli, Encoding.UTF8);
|
||||
writer.Write(Magic);
|
||||
writer.Write(sourceDate.ToUnixTimeSeconds());
|
||||
writer.Write7BitEncodedInt(systems.Count);
|
||||
foreach (var system in systems)
|
||||
{
|
||||
writer.Write7BitEncodedInt64(system.Number);
|
||||
writer.Write(system.Country);
|
||||
writer.Write(system.Name);
|
||||
}
|
||||
|
||||
var v4 = Flatten(list.Where(r => r.Start.AddressFamily == AddressFamily.InterNetwork), a => BinaryPrimitives.ReadUInt32BigEndian(a.GetAddressBytes()), uint.MaxValue, index);
|
||||
writer.Write7BitEncodedInt(v4.Count);
|
||||
var previous4 = 0u;
|
||||
foreach (var (start, system) in v4)
|
||||
{
|
||||
writer.Write7BitEncodedInt64((uint)start - previous4);
|
||||
writer.Write7BitEncodedInt(system + 1);
|
||||
previous4 = (uint)start;
|
||||
}
|
||||
|
||||
var v6 = Flatten(list.Where(r => r.Start.AddressFamily == AddressFamily.InterNetworkV6), a => BinaryPrimitives.ReadUInt128BigEndian(a.GetAddressBytes()), UInt128.MaxValue, index);
|
||||
writer.Write7BitEncodedInt(v6.Count);
|
||||
var previous6 = UInt128.Zero;
|
||||
foreach (var (start, system) in v6)
|
||||
{
|
||||
WriteBig(writer, start - previous6);
|
||||
writer.Write7BitEncodedInt(system + 1);
|
||||
previous6 = start;
|
||||
}
|
||||
}
|
||||
|
||||
static List<(UInt128 Start, int System)> Flatten(IEnumerable<AsnRange> ranges, Func<IPAddress, UInt128> number, UInt128 max, Dictionary<uint, int> index)
|
||||
{
|
||||
var result = new List<(UInt128 Start, int System)>();
|
||||
var nextFree = UInt128.Zero;
|
||||
var lastSystem = -1;
|
||||
var full = false;
|
||||
foreach (var range in ranges.Select(r => (Start: number(r.Start), End: number(r.End), System: index[r.Number])).OrderBy(r => r.Start))
|
||||
{
|
||||
if (full || range.End < nextFree) continue;
|
||||
var start = range.Start < nextFree ? nextFree : range.Start;
|
||||
if (start > nextFree && lastSystem != -1)
|
||||
{
|
||||
result.Add((nextFree, -1));
|
||||
lastSystem = -1;
|
||||
}
|
||||
if (range.System != lastSystem) result.Add((start, range.System));
|
||||
lastSystem = range.System;
|
||||
full = range.End == max;
|
||||
nextFree = full ? max : range.End + 1;
|
||||
}
|
||||
if (lastSystem != -1 && !full) result.Add((nextFree, -1));
|
||||
return result;
|
||||
}
|
||||
|
||||
static int Search<T>(T[] starts, T value, int[] systems) where T : IComparable<T>
|
||||
{
|
||||
var position = Array.BinarySearch(starts, value);
|
||||
if (position < 0) position = ~position - 1;
|
||||
return position < 0 ? -1 : systems[position];
|
||||
}
|
||||
|
||||
static void WriteBig(BinaryWriter writer, UInt128 value)
|
||||
{
|
||||
do
|
||||
{
|
||||
var low = (byte)(value & 0x7F);
|
||||
value >>= 7;
|
||||
writer.Write(value == UInt128.Zero ? low : (byte)(low | 0x80));
|
||||
}
|
||||
while (value != UInt128.Zero);
|
||||
}
|
||||
|
||||
static UInt128 ReadBig(BinaryReader reader)
|
||||
{
|
||||
var value = UInt128.Zero;
|
||||
var shift = 0;
|
||||
while (true)
|
||||
{
|
||||
var b = reader.ReadByte();
|
||||
value |= (UInt128)(b & 0x7F) << shift;
|
||||
if ((b & 0x80) == 0) return value;
|
||||
shift += 7;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MeWeb.Core.Knowledge;
|
||||
|
||||
public class ProviderFact
|
||||
{
|
||||
public string Value { get; set; }
|
||||
public decimal? Price { get; set; }
|
||||
public string Currency { get; set; }
|
||||
public string Basis { get; set; }
|
||||
public string Source { get; set; }
|
||||
public string Date { get; set; }
|
||||
}
|
||||
|
||||
public class ProviderProfile
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Country { get; set; }
|
||||
public List<uint> Asns { get; set; } = [];
|
||||
public string Sheet { get; set; }
|
||||
public ProviderFact Ipv4 { get; set; }
|
||||
public ProviderFact PublicAddress { get; set; }
|
||||
public ProviderFact Ipv6 { get; set; }
|
||||
public ProviderFact Port25 { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
|
||||
[JsonSerializable(typeof(ProviderProfile))]
|
||||
public partial class KnowledgeJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
public sealed class KnowledgeBase
|
||||
{
|
||||
readonly Lazy<AsnTable> asn = new(LoadAsn);
|
||||
readonly Lazy<List<ProviderProfile>> providers = new(LoadProviders);
|
||||
|
||||
public bool HasAsnTable => typeof(KnowledgeBase).Assembly.GetManifestResourceInfo("ip2asn.bin") is not null;
|
||||
|
||||
public AsnTable Asn => asn.Value;
|
||||
|
||||
public IReadOnlyList<ProviderProfile> Providers => providers.Value;
|
||||
|
||||
public ProviderProfile ProviderFor(uint number) => number == 0 ? null : Providers.FirstOrDefault(p => p.Asns.Contains(number));
|
||||
|
||||
static AsnTable LoadAsn()
|
||||
{
|
||||
using var stream = typeof(KnowledgeBase).Assembly.GetManifestResourceStream("ip2asn.bin");
|
||||
return stream is null ? null : AsnTable.Read(stream);
|
||||
}
|
||||
|
||||
static List<ProviderProfile> LoadProviders()
|
||||
{
|
||||
var assembly = typeof(KnowledgeBase).Assembly;
|
||||
var list = new List<ProviderProfile>();
|
||||
foreach (var name in assembly.GetManifestResourceNames().Where(n => n.StartsWith("isp.", StringComparison.Ordinal)).Order(StringComparer.Ordinal))
|
||||
{
|
||||
using var stream = assembly.GetManifestResourceStream(name);
|
||||
var profile = JsonSerializer.Deserialize(stream, KnowledgeJsonContext.Default.ProviderProfile);
|
||||
if (profile is not null) list.Add(profile);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>MeWeb.Core</RootNamespace>
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
<KnowledgeFile>$(MSBuildThisFileDirectory)..\..\artifacts\knowledge\ip2asn.bin</KnowledgeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="..\..\data\isps\*.json" LogicalName="isp.%(Filename).json" />
|
||||
<EmbeddedResource Include="$(KnowledgeFile)" LogicalName="ip2asn.bin" Condition="Exists('$(KnowledgeFile)')" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MeWeb.Shared\MeWeb.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace MeWeb.Core.Net;
|
||||
|
||||
public enum AddressKind
|
||||
{
|
||||
Public,
|
||||
Private,
|
||||
Shared,
|
||||
Loopback,
|
||||
LinkLocal,
|
||||
UniqueLocal,
|
||||
Reserved,
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
public static class Addresses
|
||||
{
|
||||
public static AddressKind Classify(IPAddress address)
|
||||
{
|
||||
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
|
||||
if (address.AddressFamily == AddressFamily.InterNetwork) return ClassifyV4(address.GetAddressBytes());
|
||||
if (address.Equals(IPAddress.IPv6Loopback)) return AddressKind.Loopback;
|
||||
if (address.Equals(IPAddress.IPv6Any)) return AddressKind.Unspecified;
|
||||
if (address.IsIPv6LinkLocal) return AddressKind.LinkLocal;
|
||||
if (address.IsIPv6UniqueLocal) return AddressKind.UniqueLocal;
|
||||
if (address.IsIPv6Multicast || address.IsIPv6SiteLocal) return AddressKind.Reserved;
|
||||
var bytes = address.GetAddressBytes();
|
||||
if (bytes is [0x20, 0x01, 0x0d, 0xb8, ..]) return AddressKind.Reserved;
|
||||
if (bytes is [0x00, 0x64, 0xff, 0x9b, ..]) return AddressKind.Reserved;
|
||||
return (bytes[0] & 0xe0) == 0x20 ? AddressKind.Public : AddressKind.Reserved;
|
||||
}
|
||||
|
||||
public static bool IsPublic(IPAddress address) => address is not null && Classify(address) == AddressKind.Public;
|
||||
|
||||
public static IPAddress Parse(string text) => IPAddress.TryParse(text, out var address) ? address : null;
|
||||
|
||||
public static bool SameAddress(string left, string right)
|
||||
{
|
||||
var a = Parse(left);
|
||||
var b = Parse(right);
|
||||
if (a is null || b is null) return false;
|
||||
if (a.IsIPv4MappedToIPv6) a = a.MapToIPv4();
|
||||
if (b.IsIPv4MappedToIPv6) b = b.MapToIPv4();
|
||||
return a.Equals(b);
|
||||
}
|
||||
|
||||
static AddressKind ClassifyV4(byte[] b) => b switch
|
||||
{
|
||||
[0, ..] => AddressKind.Unspecified,
|
||||
[10, ..] => AddressKind.Private,
|
||||
[127, ..] => AddressKind.Loopback,
|
||||
[169, 254, ..] => AddressKind.LinkLocal,
|
||||
[172, >= 16 and <= 31, ..] => AddressKind.Private,
|
||||
[192, 168, ..] => AddressKind.Private,
|
||||
[100, >= 64 and <= 127, ..] => AddressKind.Shared,
|
||||
[192, 0, 0 or 2, ..] => AddressKind.Reserved,
|
||||
[198, 18 or 19, ..] => AddressKind.Reserved,
|
||||
[198, 51, 100, ..] => AddressKind.Reserved,
|
||||
[203, 0, 113, ..] => AddressKind.Reserved,
|
||||
[>= 224, ..] => AddressKind.Reserved,
|
||||
_ => AddressKind.Public,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace MeWeb.Core.Net;
|
||||
|
||||
public enum LinkKind
|
||||
{
|
||||
Unknown,
|
||||
Wifi,
|
||||
Cable,
|
||||
Mobile,
|
||||
}
|
||||
|
||||
public enum DeviceKind
|
||||
{
|
||||
Computer,
|
||||
Phone,
|
||||
}
|
||||
|
||||
public class LocalInterface
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public LinkKind Kind { get; set; }
|
||||
public long SpeedMbps { get; set; }
|
||||
public List<string> Addresses { get; set; } = [];
|
||||
public List<string> Gateways { get; set; } = [];
|
||||
}
|
||||
|
||||
public class LocalSnapshot
|
||||
{
|
||||
public List<LocalInterface> Interfaces { get; set; } = [];
|
||||
public List<string> Dns { get; set; } = [];
|
||||
|
||||
public LocalInterface Active =>
|
||||
Interfaces.FirstOrDefault(i => i.Gateways.Any(IsV4)) ??
|
||||
Interfaces.FirstOrDefault(i => i.Gateways.Count > 0) ??
|
||||
Interfaces.FirstOrDefault(i => i.Kind == LinkKind.Mobile) ??
|
||||
Interfaces.FirstOrDefault();
|
||||
|
||||
public string GatewayV4 => Active?.Gateways.FirstOrDefault(IsV4);
|
||||
|
||||
public string LocalV4 => Active?.Addresses.FirstOrDefault(IsV4);
|
||||
|
||||
public List<string> GlobalV6 => Active?.Addresses.Where(a => !IsV4(a) && Net.Addresses.IsPublic(Net.Addresses.Parse(a))).ToList() ?? [];
|
||||
|
||||
static bool IsV4(string text) => Net.Addresses.Parse(text)?.AddressFamily == AddressFamily.InterNetwork;
|
||||
}
|
||||
|
||||
public interface IPlatform
|
||||
{
|
||||
DeviceKind Device { get; }
|
||||
|
||||
LocalSnapshot ReadNetwork();
|
||||
|
||||
IDisposable AllowMulticast();
|
||||
}
|
||||
|
||||
public class SystemPlatform : IPlatform
|
||||
{
|
||||
public virtual DeviceKind Device => DeviceKind.Computer;
|
||||
|
||||
public virtual IDisposable AllowMulticast() => new Nothing();
|
||||
|
||||
public virtual LocalSnapshot ReadNetwork()
|
||||
{
|
||||
var snapshot = new LocalSnapshot();
|
||||
NetworkInterface[] adapters;
|
||||
try
|
||||
{
|
||||
adapters = NetworkInterface.GetAllNetworkInterfaces();
|
||||
}
|
||||
catch (Exception ex) when (ex is NetworkInformationException or PlatformNotSupportedException)
|
||||
{
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
foreach (var adapter in adapters)
|
||||
{
|
||||
if (adapter.OperationalStatus != OperationalStatus.Up || adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||
try
|
||||
{
|
||||
var properties = adapter.GetIPProperties();
|
||||
var item = new LocalInterface
|
||||
{
|
||||
Name = adapter.Name,
|
||||
Kind = KindOf(adapter),
|
||||
SpeedMbps = SpeedOf(adapter),
|
||||
Addresses = properties.UnicastAddresses.Select(u => Clean(u.Address)).Where(a => a is not null).ToList(),
|
||||
Gateways = GatewaysOf(properties),
|
||||
};
|
||||
if (item.Addresses.Count > 0) snapshot.Interfaces.Add(item);
|
||||
foreach (var dns in DnsOf(properties))
|
||||
if (!snapshot.Dns.Contains(dns)) snapshot.Dns.Add(dns);
|
||||
}
|
||||
catch (Exception ex) when (ex is NetworkInformationException or PlatformNotSupportedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
static string Clean(IPAddress address)
|
||||
{
|
||||
var kind = Net.Addresses.Classify(address);
|
||||
if (kind is AddressKind.Loopback or AddressKind.LinkLocal or AddressKind.Unspecified) return null;
|
||||
if (address.AddressFamily == AddressFamily.InterNetworkV6) address = new IPAddress(address.GetAddressBytes());
|
||||
return address.ToString();
|
||||
}
|
||||
|
||||
static List<string> GatewaysOf(IPInterfaceProperties properties)
|
||||
{
|
||||
try
|
||||
{
|
||||
return properties.GatewayAddresses.Select(g => g.Address)
|
||||
.Where(a => !a.Equals(IPAddress.Any) && !a.Equals(IPAddress.IPv6Any))
|
||||
.OrderBy(a => a.AddressFamily == AddressFamily.InterNetwork ? 0 : 1)
|
||||
.Select(a => a.IsIPv6LinkLocal ? new IPAddress(a.GetAddressBytes(), a.ScopeId).ToString() : a.ToString())
|
||||
.Distinct().ToList();
|
||||
}
|
||||
catch (PlatformNotSupportedException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static IEnumerable<string> DnsOf(IPInterfaceProperties properties)
|
||||
{
|
||||
try
|
||||
{
|
||||
return properties.DnsAddresses.Select(a => a.ToString()).ToList();
|
||||
}
|
||||
catch (PlatformNotSupportedException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static LinkKind KindOf(NetworkInterface adapter)
|
||||
{
|
||||
if (OperatingSystem.IsLinux() && (Directory.Exists($"/sys/class/net/{adapter.Name}/wireless") || Directory.Exists($"/sys/class/net/{adapter.Name}/phy80211")))
|
||||
return LinkKind.Wifi;
|
||||
return adapter.NetworkInterfaceType switch
|
||||
{
|
||||
NetworkInterfaceType.Wireless80211 => LinkKind.Wifi,
|
||||
NetworkInterfaceType.Ethernet or NetworkInterfaceType.GigabitEthernet or NetworkInterfaceType.FastEthernetT or NetworkInterfaceType.FastEthernetFx => LinkKind.Cable,
|
||||
NetworkInterfaceType.Wwanpp or NetworkInterfaceType.Wwanpp2 or NetworkInterfaceType.Ppp => LinkKind.Mobile,
|
||||
_ => LinkKind.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
static long SpeedOf(NetworkInterface adapter)
|
||||
{
|
||||
try
|
||||
{
|
||||
return adapter.Speed > 0 ? adapter.Speed / 1_000_000 : 0;
|
||||
}
|
||||
catch (Exception ex) when (ex is PlatformNotSupportedException or NetworkInformationException)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Nothing : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
|
||||
namespace MeWeb.Core.Net;
|
||||
|
||||
public class Hop
|
||||
{
|
||||
public int Distance { get; set; }
|
||||
public string Address { get; set; }
|
||||
public AddressKind Kind { get; set; }
|
||||
public bool IsDestination { get; set; }
|
||||
}
|
||||
|
||||
public static class Route
|
||||
{
|
||||
public const int MaxHops = 8;
|
||||
|
||||
public static async Task<List<Hop>> Trace(IPAddress target, CancellationToken ct)
|
||||
{
|
||||
var replies = await Task.WhenAll(Enumerable.Range(1, MaxHops).Select(distance => Probe(target, distance, ct)));
|
||||
if (replies.All(r => r is null)) return [];
|
||||
var hops = new List<Hop>();
|
||||
foreach (var hop in replies.Where(r => r is not null))
|
||||
{
|
||||
hops.Add(hop);
|
||||
if (hop.IsDestination) break;
|
||||
}
|
||||
return hops;
|
||||
}
|
||||
|
||||
public static List<Hop> BeforeInternet(IEnumerable<Hop> hops)
|
||||
{
|
||||
var list = new List<Hop>();
|
||||
foreach (var hop in hops)
|
||||
{
|
||||
if (hop.Address is not null && hop.Kind == AddressKind.Public) break;
|
||||
list.Add(hop);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
static async Task<Hop> Probe(IPAddress target, int distance, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ping = new Ping();
|
||||
var reply = await ping.SendPingAsync(target, TimeSpan.FromSeconds(2), [], new PingOptions(distance, false), ct);
|
||||
var answered = reply.Status is IPStatus.TtlExpired or IPStatus.TimeExceeded or IPStatus.Success;
|
||||
var address = answered && reply.Address is not null && !reply.Address.Equals(IPAddress.Any) ? reply.Address : null;
|
||||
return new Hop
|
||||
{
|
||||
Distance = distance,
|
||||
Address = address?.ToString(),
|
||||
Kind = address is null ? AddressKind.Unspecified : Addresses.Classify(address),
|
||||
IsDestination = reply.Status == IPStatus.Success,
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is PingException or PlatformNotSupportedException or InvalidOperationException or NotSupportedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace MeWeb.Core.Router;
|
||||
|
||||
public enum Answer
|
||||
{
|
||||
Silent,
|
||||
Answered,
|
||||
Refused,
|
||||
}
|
||||
|
||||
public class RouterReport
|
||||
{
|
||||
public string Gateway { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Make { get; set; }
|
||||
public string Model { get; set; }
|
||||
public Answer Upnp { get; set; }
|
||||
public int UpnpVersion { get; set; }
|
||||
public int UpnpError { get; set; }
|
||||
public Answer NatPmp { get; set; }
|
||||
public int NatPmpResult { get; set; } = -1;
|
||||
public Answer Pcp { get; set; }
|
||||
public int PcpResult { get; set; } = -1;
|
||||
public string WanAddress { get; set; }
|
||||
|
||||
public bool AnyAnswer => Upnp != Answer.Silent || NatPmp != Answer.Silent || Pcp != Answer.Silent;
|
||||
|
||||
public bool AnyMapping => Upnp == Answer.Answered || NatPmp == Answer.Answered || Pcp == Answer.Answered;
|
||||
|
||||
public string Label => !string.IsNullOrWhiteSpace(Model) ? Model : !string.IsNullOrWhiteSpace(Name) ? Name : Make;
|
||||
}
|
||||
Reference in new issue
Block a user