me-web? 0.5: the shared exit measured, the route from the server, the network's upstreams
- Behind your provider's shared address, me-web? now shows how its exit hands out ports (kept, one after another, from one block, or at random), the range it gave this device, and how many customers may share the address at most; the estimate sharpens with each check on the same address. - It tests whether two devices behind the same address can reach each other (hairpin), and how long an unused connection stays open: the card says "measuring" for about two minutes after the check, then the answer. - With the test from outside, the publisher's server also traces the route back to your address: your provider's routers and the networks in between appear on the path, with their names. - The provider card says which networks your provider reaches the internet through. - On mobile data, the path shows the operator and the type of network (5G, 4G) read from the phone; on Android 13 and later the phone asks once for permission to read the network type. - The speed card adds the round trip to the publisher's server. - The network data now comes from the publisher's server, refreshed there from open sources: iptoasn.com, DB-IP Lite (CC BY 4.0), the University of Oregon Route Views Project (CC BY 4.0) and the Android Open Source Project's list of mobile operators (Apache 2.0); the release notes list each source's date and checksum. - Source links in the cards wrap instead of being cut off. - New strings, drafted in every language. 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
8caed6b28d
commit
eb8be87284
145 files changed
+3704
-193
No files matched your search
@@ -24,6 +24,7 @@ public class RunReport
|
||||
public Mail.MailReport Mail { get; set; }
|
||||
public DnsReport Dns { get; set; }
|
||||
public DeviceReport DeviceReport { get; set; }
|
||||
public CellularReport Cellular { get; set; }
|
||||
public SpeedReport Speed { get; set; }
|
||||
public List<UseFinding> Uses { get; set; } = [];
|
||||
public HistorySummary History { get; set; }
|
||||
@@ -39,7 +40,11 @@ public class CheckRunner(IPlatform platform, ServerProbe server, KnowledgeBase k
|
||||
|
||||
void Settle(Finding finding)
|
||||
{
|
||||
lock (run.Findings) run.Findings.Add(finding);
|
||||
lock (run.Findings)
|
||||
{
|
||||
run.Findings.RemoveAll(f => f.Id == finding.Id);
|
||||
run.Findings.Add(finding);
|
||||
}
|
||||
report(finding, run);
|
||||
}
|
||||
|
||||
@@ -68,7 +73,8 @@ public class CheckRunner(IPlatform platform, ServerProbe server, KnowledgeBase k
|
||||
var connection = Step(CheckId.Connection, async () =>
|
||||
{
|
||||
run.Local = await local;
|
||||
Settle(Verdicts.Connection(run.Local));
|
||||
if (run.Local?.Active?.Kind == LinkKind.Mobile) run.Cellular = await platform.ReadCellular(ct);
|
||||
Settle(Verdicts.Connection(run.Local, run.Cellular, knowledge));
|
||||
});
|
||||
|
||||
var onlineDone = Step(CheckId.Online, async () =>
|
||||
@@ -116,7 +122,16 @@ public class CheckRunner(IPlatform platform, ServerProbe server, KnowledgeBase k
|
||||
{
|
||||
await connection;
|
||||
run.Stun = await Stun.Compare((Shared.Endpoints.SecondStunHost, Shared.Endpoints.SecondStunPort), (Shared.Endpoints.StunHost, Shared.Endpoints.StunPort), run.Local, ct);
|
||||
Settle(Verdicts.Behaviour(run.Stun));
|
||||
run.Stun.Ports = await Stun.Ports((Shared.Endpoints.StunHost, Shared.Endpoints.StunPort), 16, ct);
|
||||
await nat;
|
||||
var (low, high) = history.PortRange(run.PublicV4);
|
||||
Stun.Classify(run.Stun.Ports, low, high);
|
||||
Settle(Verdicts.Behaviour(run.Stun, run.Nat));
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Stun.Lifetime((Shared.Endpoints.StunHost, Shared.Endpoints.StunPort), run.Stun.Ports, [30, 120], ct);
|
||||
Settle(Verdicts.Behaviour(run.Stun, run.Nat));
|
||||
}, ct);
|
||||
});
|
||||
|
||||
var mail = Step(CheckId.Mail, async () =>
|
||||
@@ -165,6 +180,8 @@ public class CheckRunner(IPlatform platform, ServerProbe server, KnowledgeBase k
|
||||
PublicV6 = run.PublicV6,
|
||||
Asn = number,
|
||||
Nat = run.Nat.ToString(),
|
||||
PortLow = run.Stun?.Ports?.Pattern is PortPattern.InOrder or PortPattern.Block ? run.Stun.Ports.Low : 0,
|
||||
PortHigh = run.Stun?.Ports?.Pattern is PortPattern.InOrder or PortPattern.Block ? run.Stun.Ports.High : 0,
|
||||
});
|
||||
Settle(Verdicts.History(run.History));
|
||||
run.Uses = Uses.Decide(run);
|
||||
|
||||
@@ -41,7 +41,7 @@ public static class Verdicts
|
||||
return finding;
|
||||
}
|
||||
|
||||
public static Finding Connection(LocalSnapshot local)
|
||||
public static Finding Connection(LocalSnapshot local, CellularReport cellular, KnowledgeBase knowledge)
|
||||
{
|
||||
var active = local.Active;
|
||||
if (active is null) return new Finding { Id = CheckId.Connection, Outcome = Outcome.Bad, Summary = "ConnectionNone" };
|
||||
@@ -64,6 +64,12 @@ public static class Verdicts
|
||||
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)));
|
||||
if (cellular is null) return finding;
|
||||
var name = !string.IsNullOrWhiteSpace(cellular.Operator) ? cellular.Operator.Trim() : knowledge.CarrierFor(cellular.NetworkCode) ?? cellular.Carrier;
|
||||
var code = cellular.NetworkCode is { Length: >= 5 } network ? $"{network[..3]} {network[3..]}" : null;
|
||||
finding.With("FactOperator", code is null ? name : name is null ? code : $"{name} ({code})");
|
||||
finding.With("FactRadio", cellular.Radio);
|
||||
if (cellular.Roaming is not null) finding.WithKey("FactRoaming", cellular.Roaming == true ? "ValueYes" : "ValueNo");
|
||||
return finding;
|
||||
}
|
||||
|
||||
@@ -121,6 +127,8 @@ public static class Verdicts
|
||||
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));
|
||||
var upstreams = knowledge.NetworkFor(main.Number)?.Upstreams ?? [];
|
||||
if (upstreams.Count > 0) finding.With("FactUpstreams", string.Join("\n", upstreams.Select(u => $"{knowledge.NameOf(u) ?? "AS" + u} (AS{u})")));
|
||||
if (profile is null) return finding;
|
||||
AddFact(finding, "FactProviderIpv4", "ProviderIpv4", profile.Ipv4);
|
||||
AddFact(finding, "FactProviderPublic", "ProviderPublic", profile.PublicAddress);
|
||||
@@ -204,7 +212,7 @@ public static class Verdicts
|
||||
};
|
||||
}
|
||||
|
||||
public static Finding Behaviour(StunReport stun)
|
||||
public static Finding Behaviour(StunReport stun, NatKind nat)
|
||||
{
|
||||
var finding = new Finding { Id = CheckId.Behaviour };
|
||||
(finding.Outcome, finding.Summary) = stun.Behaviour switch
|
||||
@@ -217,6 +225,29 @@ public static class Verdicts
|
||||
finding.With("FactStunFirst", stun.FirstMapped is null ? null : $"{stun.FirstServer} → {stun.FirstMapped}");
|
||||
finding.With("FactStunSecond", stun.SecondMapped is null ? null : $"{stun.SecondServer} → {stun.SecondMapped}");
|
||||
finding.With("FactLocalPort", stun.LocalPort > 0 ? stun.LocalPort.ToString(CultureInfo.InvariantCulture) : null);
|
||||
var ports = stun.Ports;
|
||||
if (ports is null) return finding;
|
||||
if (ports.Pattern != PortPattern.Unknown)
|
||||
{
|
||||
var low = ports.Low.ToString(CultureInfo.InvariantCulture);
|
||||
var high = ports.High.ToString(CultureInfo.InvariantCulture);
|
||||
finding.WithKey("FactPortSlice", ports.Pattern switch
|
||||
{
|
||||
PortPattern.Kept => "ValuePortsKept",
|
||||
PortPattern.InOrder => "ValuePortsInOrder",
|
||||
PortPattern.Block => "ValuePortsBlock",
|
||||
_ => "ValuePortsScattered",
|
||||
}, low, high);
|
||||
if (nat == NatKind.Shared && ports.BlockSize >= 64)
|
||||
finding.WithKey("FactSharers", "ValueUpTo", (64512 / ports.BlockSize).ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
if (ports.Hairpin is not null) finding.WithKey("FactHairpinExit", ports.Hairpin == true ? "ValueYes" : "ValueNo");
|
||||
if (!ports.LifetimeDone) finding.WithKey("FactMappingLife", "ValueMeasuring");
|
||||
else if (ports.KeptAtLeast > 0 && ports.LostBefore > ports.KeptAtLeast)
|
||||
finding.WithKey("FactMappingLife", "ValueBetweenSeconds", ports.KeptAtLeast.ToString(CultureInfo.InvariantCulture), ports.LostBefore.ToString(CultureInfo.InvariantCulture));
|
||||
else if (ports.KeptAtLeast > 0) finding.WithKey("FactMappingLife", "ValueAtLeastSeconds", ports.KeptAtLeast.ToString(CultureInfo.InvariantCulture));
|
||||
else if (ports.LostBefore > 0) finding.WithKey("FactMappingLife", "ValueUnderSeconds", ports.LostBefore.ToString(CultureInfo.InvariantCulture));
|
||||
if (ports.LifetimeDone && ports.LostBefore is > 0 and <= 30 && finding.Outcome == Outcome.Good) finding.Outcome = Outcome.Caution;
|
||||
return finding;
|
||||
}
|
||||
|
||||
@@ -265,6 +296,8 @@ public static class Verdicts
|
||||
if (reach.V4 is null && reach.V4Refusal is not null) finding.WithKey("FactReachV4", "Refusal" + Pascal(reach.V4Refusal));
|
||||
if (reach.V6 is null && reach.V6Refusal is not null) finding.WithKey("FactReachV6", "Refusal" + Pascal(reach.V6Refusal));
|
||||
if (reach.Hairpin is not null) finding.WithKey("FactHairpin", reach.Hairpin == true ? "ValueHairpinYes" : "ValueHairpinNo");
|
||||
var route = reach.V4?.Route ?? reach.V6?.Route ?? [];
|
||||
if (route.Count > 0) finding.With("FactRouteBack", string.Join(" → ", route.Select(h => h.Name ?? h.Address ?? "*")));
|
||||
if (reach.V4?.Ports.Any(p => p.Outcome == ProbeOutcome.OpenNotYou && p.Port is 80 or 443) == true && !mapped)
|
||||
finding.WithKey("FactHint", "HintSomethingAnswers");
|
||||
return finding;
|
||||
@@ -362,6 +395,7 @@ public static class Verdicts
|
||||
finding.WithKey("FactDownload", "ValueMbpsDecimal", speed.DownMbps.ToString("0.#", CultureInfo.InvariantCulture));
|
||||
finding.WithKey("FactUpload", "ValueMbpsDecimal", speed.UpMbps.ToString("0.#", CultureInfo.InvariantCulture));
|
||||
finding.WithKey("FactSample", "ValueSample", (speed.DownBytes / 1048576).ToString(CultureInfo.InvariantCulture), (speed.UpBytes / 1048576).ToString(CultureInfo.InvariantCulture));
|
||||
if (speed.RoundTripMs > 0) finding.WithKey("FactRoundTrip", "ValueMilliseconds", speed.RoundTripMs.ToString(CultureInfo.InvariantCulture));
|
||||
return finding;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ public class HistoryEntry
|
||||
public string PublicV6 { get; set; }
|
||||
public uint Asn { get; set; }
|
||||
public string Nat { get; set; }
|
||||
public int PortLow { get; set; }
|
||||
public int PortHigh { get; set; }
|
||||
}
|
||||
|
||||
public class HistorySummary
|
||||
@@ -73,6 +75,12 @@ public class HistoryStore(string folder)
|
||||
return Summarise(entries, entry.Network);
|
||||
}
|
||||
|
||||
public (int Low, int High) PortRange(string publicV4)
|
||||
{
|
||||
var seen = Read().Where(e => publicV4 is not null && e.PublicV4 == publicV4 && e.PortLow > 0 && e.PortHigh >= e.PortLow).ToList();
|
||||
return seen.Count == 0 ? (0, 0) : (seen.Min(e => e.PortLow), seen.Max(e => e.PortHigh));
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (gate)
|
||||
|
||||
@@ -46,6 +46,21 @@ public sealed class AsnTable
|
||||
return index < 0 ? null : systems[index];
|
||||
}
|
||||
|
||||
public AsnEntry Find(uint number)
|
||||
{
|
||||
var low = 0;
|
||||
var high = systems.Length - 1;
|
||||
while (low <= high)
|
||||
{
|
||||
var middle = (low + high) / 2;
|
||||
var found = systems[middle].Number;
|
||||
if (found == number) return systems[middle];
|
||||
if (found < number) low = middle + 1;
|
||||
else high = middle - 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static AsnTable Read(Stream compressed)
|
||||
{
|
||||
using var brotli = new BrotliStream(compressed, CompressionMode.Decompress);
|
||||
@@ -82,9 +97,19 @@ public sealed class AsnTable
|
||||
|
||||
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 known = new Dictionary<uint, AsnEntry>();
|
||||
var v4Ranges = new List<(UInt128 Start, UInt128 End, uint Number)>();
|
||||
var v6Ranges = new List<(UInt128 Start, UInt128 End, uint Number)>();
|
||||
foreach (var range in ranges)
|
||||
{
|
||||
if (range.Number == 0) continue;
|
||||
if (!known.ContainsKey(range.Number)) known[range.Number] = new AsnEntry { Number = range.Number, Country = range.Country ?? "", Name = range.Name ?? "" };
|
||||
if (range.Start.AddressFamily == AddressFamily.InterNetwork)
|
||||
v4Ranges.Add((BinaryPrimitives.ReadUInt32BigEndian(range.Start.GetAddressBytes()), BinaryPrimitives.ReadUInt32BigEndian(range.End.GetAddressBytes()), range.Number));
|
||||
else
|
||||
v6Ranges.Add((BinaryPrimitives.ReadUInt128BigEndian(range.Start.GetAddressBytes()), BinaryPrimitives.ReadUInt128BigEndian(range.End.GetAddressBytes()), range.Number));
|
||||
}
|
||||
var systems = known.Values.OrderBy(s => s.Number).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);
|
||||
@@ -99,7 +124,7 @@ public sealed class AsnTable
|
||||
writer.Write(system.Name);
|
||||
}
|
||||
|
||||
var v4 = Flatten(list.Where(r => r.Start.AddressFamily == AddressFamily.InterNetwork), a => BinaryPrimitives.ReadUInt32BigEndian(a.GetAddressBytes()), uint.MaxValue, index);
|
||||
var v4 = Flatten(v4Ranges, uint.MaxValue, index);
|
||||
writer.Write7BitEncodedInt(v4.Count);
|
||||
var previous4 = 0u;
|
||||
foreach (var (start, system) in v4)
|
||||
@@ -109,7 +134,7 @@ public sealed class AsnTable
|
||||
previous4 = (uint)start;
|
||||
}
|
||||
|
||||
var v6 = Flatten(list.Where(r => r.Start.AddressFamily == AddressFamily.InterNetworkV6), a => BinaryPrimitives.ReadUInt128BigEndian(a.GetAddressBytes()), UInt128.MaxValue, index);
|
||||
var v6 = Flatten(v6Ranges, UInt128.MaxValue, index);
|
||||
writer.Write7BitEncodedInt(v6.Count);
|
||||
var previous6 = UInt128.Zero;
|
||||
foreach (var (start, system) in v6)
|
||||
@@ -120,14 +145,16 @@ public sealed class AsnTable
|
||||
}
|
||||
}
|
||||
|
||||
static List<(UInt128 Start, int System)> Flatten(IEnumerable<AsnRange> ranges, Func<IPAddress, UInt128> number, UInt128 max, Dictionary<uint, int> index)
|
||||
static List<(UInt128 Start, int System)> Flatten(List<(UInt128 Start, UInt128 End, uint Number)> ranges, UInt128 max, Dictionary<uint, int> index)
|
||||
{
|
||||
ranges.Sort((a, b) => a.Start.CompareTo(b.Start));
|
||||
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))
|
||||
foreach (var (rangeStart, rangeEnd, number) in ranges)
|
||||
{
|
||||
var range = (Start: rangeStart, End: rangeEnd, System: index[number]);
|
||||
if (full || range.End < nextFree) continue;
|
||||
var start = range.Start < nextFree ? nextFree : range.Start;
|
||||
if (start > nextFree && lastSystem != -1)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
|
||||
namespace MeWeb.Core.Knowledge;
|
||||
|
||||
public class NetworkInfo
|
||||
{
|
||||
public uint Number { get; set; }
|
||||
public string Organisation { get; set; }
|
||||
public List<uint> Upstreams { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class NetworkTable
|
||||
{
|
||||
const uint Magic = 0x324B574D;
|
||||
|
||||
readonly Dictionary<uint, NetworkInfo> networks = [];
|
||||
|
||||
public DateTimeOffset BuiltAt { get; private set; }
|
||||
|
||||
public int Count => networks.Count;
|
||||
|
||||
public NetworkInfo Find(uint number) => networks.GetValueOrDefault(number);
|
||||
|
||||
public static NetworkTable 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? network table");
|
||||
var table = new NetworkTable { BuiltAt = DateTimeOffset.FromUnixTimeSeconds(reader.ReadInt64()) };
|
||||
var count = reader.Read7BitEncodedInt();
|
||||
var previous = 0u;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
previous += (uint)reader.Read7BitEncodedInt64();
|
||||
var info = new NetworkInfo { Number = previous, Organisation = reader.ReadString() };
|
||||
var upstreams = reader.Read7BitEncodedInt();
|
||||
for (var u = 0; u < upstreams; u++) info.Upstreams.Add((uint)reader.Read7BitEncodedInt64());
|
||||
if (info.Organisation.Length == 0) info.Organisation = null;
|
||||
table.networks[info.Number] = info;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
public static void Write(Stream output, DateTimeOffset builtAt, IEnumerable<NetworkInfo> networks)
|
||||
{
|
||||
var list = networks.Where(n => n.Number != 0).OrderBy(n => n.Number).ToList();
|
||||
using var brotli = new BrotliStream(output, CompressionLevel.SmallestSize, leaveOpen: true);
|
||||
using var writer = new BinaryWriter(brotli, Encoding.UTF8);
|
||||
writer.Write(Magic);
|
||||
writer.Write(builtAt.ToUnixTimeSeconds());
|
||||
writer.Write7BitEncodedInt(list.Count);
|
||||
var previous = 0u;
|
||||
foreach (var info in list)
|
||||
{
|
||||
writer.Write7BitEncodedInt64(info.Number - previous);
|
||||
writer.Write(info.Organisation ?? "");
|
||||
writer.Write7BitEncodedInt(info.Upstreams.Count);
|
||||
foreach (var upstream in info.Upstreams) writer.Write7BitEncodedInt64(upstream);
|
||||
previous = info.Number;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public class ProviderProfile
|
||||
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
|
||||
[JsonSerializable(typeof(ProviderProfile))]
|
||||
[JsonSerializable(typeof(List<Carrier>))]
|
||||
public partial class KnowledgeJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -35,6 +36,8 @@ public sealed class KnowledgeBase
|
||||
{
|
||||
readonly Lazy<AsnTable> asn = new(LoadAsn);
|
||||
readonly Lazy<List<ProviderProfile>> providers = new(LoadProviders);
|
||||
readonly Lazy<NetworkTable> networks = new(LoadNetworks);
|
||||
readonly Lazy<Dictionary<string, string>> carriers = new(LoadCarriers);
|
||||
|
||||
public bool HasAsnTable => typeof(KnowledgeBase).Assembly.GetManifestResourceInfo("ip2asn.bin") is not null;
|
||||
|
||||
@@ -44,6 +47,26 @@ public sealed class KnowledgeBase
|
||||
|
||||
public ProviderProfile ProviderFor(uint number) => number == 0 ? null : Providers.FirstOrDefault(p => p.Asns.Contains(number));
|
||||
|
||||
public NetworkInfo NetworkFor(uint number) => number == 0 ? null : networks.Value?.Find(number);
|
||||
|
||||
public string CarrierFor(string mccMnc) => string.IsNullOrEmpty(mccMnc) ? null : carriers.Value.GetValueOrDefault(mccMnc);
|
||||
|
||||
public string NameOf(uint number) => NetworkFor(number)?.Organisation ?? Asn?.Find(number)?.Name;
|
||||
|
||||
static NetworkTable LoadNetworks()
|
||||
{
|
||||
using var stream = typeof(KnowledgeBase).Assembly.GetManifestResourceStream("networks.bin");
|
||||
return stream is null ? null : NetworkTable.Read(stream);
|
||||
}
|
||||
|
||||
static Dictionary<string, string> LoadCarriers()
|
||||
{
|
||||
using var stream = typeof(KnowledgeBase).Assembly.GetManifestResourceStream("carriers.json");
|
||||
if (stream is null) return [];
|
||||
var list = JsonSerializer.Deserialize(stream, KnowledgeJsonContext.Default.ListCarrier) ?? [];
|
||||
return list.Where(c => c.MccMnc is not null && c.Name is not null).GroupBy(c => c.MccMnc).ToDictionary(g => g.Key, g => g.First().Name);
|
||||
}
|
||||
|
||||
static AsnTable LoadAsn()
|
||||
{
|
||||
using var stream = typeof(KnowledgeBase).Assembly.GetManifestResourceStream("ip2asn.bin");
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
|
||||
namespace MeWeb.Core.Knowledge;
|
||||
|
||||
public class Carrier
|
||||
{
|
||||
public string MccMnc { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
|
||||
public static class Sources
|
||||
{
|
||||
public static IEnumerable<AsnRange> IpToAsn(Stream gzip)
|
||||
{
|
||||
var names = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
using var reader = new StreamReader(new GZipStream(gzip, CompressionMode.Decompress));
|
||||
string line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
var parts = line.Split('\t');
|
||||
if (parts.Length < 5 || !uint.TryParse(parts[2], out var number) || number == 0) continue;
|
||||
if (!IPAddress.TryParse(parts[0], out var start) || !IPAddress.TryParse(parts[1], out var end)) continue;
|
||||
yield return new AsnRange { Start = start, End = end, Number = number, Country = Intern(names, parts[3] == "None" ? "" : parts[3]), Name = Intern(names, parts[4]) };
|
||||
}
|
||||
}
|
||||
|
||||
public static Dictionary<uint, string> DbIpOrganisations(Stream gzip)
|
||||
{
|
||||
var seen = new Dictionary<uint, Dictionary<string, int>>();
|
||||
using var reader = new StreamReader(new GZipStream(gzip, CompressionMode.Decompress));
|
||||
string line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
var first = line.IndexOf(',');
|
||||
var second = first < 0 ? -1 : line.IndexOf(',', first + 1);
|
||||
var third = second < 0 ? -1 : line.IndexOf(',', second + 1);
|
||||
if (third < 0 || !uint.TryParse(line.AsSpan(second + 1, third - second - 1), out var number) || number == 0) continue;
|
||||
var name = line[(third + 1)..].Trim();
|
||||
if (name.Length >= 2 && name[0] == '"' && name[^1] == '"') name = name[1..^1].Replace("\"\"", "\"");
|
||||
if (name.Length == 0) continue;
|
||||
if (!seen.TryGetValue(number, out var names)) seen[number] = names = [];
|
||||
names[name] = names.GetValueOrDefault(name) + 1;
|
||||
}
|
||||
return seen.ToDictionary(p => p.Key, p => p.Value.MaxBy(n => n.Value).Key);
|
||||
}
|
||||
|
||||
public static Dictionary<uint, List<uint>> Upstreams(Stream mrt, int keep = 4, double share = 0.05)
|
||||
{
|
||||
var counts = new Dictionary<uint, Dictionary<uint, int>>();
|
||||
var header = new byte[12];
|
||||
var body = new byte[1 << 16];
|
||||
while (Fill(mrt, header))
|
||||
{
|
||||
var type = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4));
|
||||
var subtype = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(6));
|
||||
var length = (int)BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(8));
|
||||
if (body.Length < length) body = new byte[length];
|
||||
if (!Fill(mrt, body.AsSpan(0, length))) break;
|
||||
if (type != 13 || subtype is not (2 or 4)) continue;
|
||||
Rib(body.AsSpan(0, length), counts);
|
||||
}
|
||||
var result = new Dictionary<uint, List<uint>>();
|
||||
foreach (var (origin, neighbours) in counts)
|
||||
{
|
||||
var total = (double)neighbours.Values.Sum();
|
||||
var chosen = neighbours.Where(n => n.Value / total >= share).OrderByDescending(n => n.Value).ThenBy(n => n.Key).Take(keep).Select(n => n.Key).ToList();
|
||||
if (chosen.Count > 0) result[origin] = chosen;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static List<Carrier> Carriers(TextReader textpb)
|
||||
{
|
||||
var list = new List<Carrier>();
|
||||
string name = null;
|
||||
string tuple = null;
|
||||
var plain = false;
|
||||
string line;
|
||||
while ((line = textpb.ReadLine()) is not null)
|
||||
{
|
||||
var text = line.Trim();
|
||||
if (text.StartsWith("carrier_id {", StringComparison.Ordinal)) name = null;
|
||||
else if (text.StartsWith("carrier_name:", StringComparison.Ordinal)) name = Quoted(text);
|
||||
else if (text.StartsWith("carrier_attribute {", StringComparison.Ordinal))
|
||||
{
|
||||
tuple = null;
|
||||
plain = true;
|
||||
}
|
||||
else if (text.StartsWith("mccmnc_tuple:", StringComparison.Ordinal)) tuple = tuple is null ? Quoted(text) : null;
|
||||
else if (text == "}" && tuple is not null)
|
||||
{
|
||||
if (plain && name is not null && list.All(c => c.MccMnc != tuple)) list.Add(new Carrier { MccMnc = tuple, Name = name });
|
||||
tuple = null;
|
||||
}
|
||||
else if (text.Length > 0 && text != "}") plain = false;
|
||||
}
|
||||
return list.OrderBy(c => c.MccMnc, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
static string Intern(Dictionary<string, string> names, string text)
|
||||
{
|
||||
if (names.TryGetValue(text, out var known)) return known;
|
||||
names[text] = text;
|
||||
return text;
|
||||
}
|
||||
|
||||
static string Quoted(string text)
|
||||
{
|
||||
var start = text.IndexOf('"');
|
||||
var end = text.LastIndexOf('"');
|
||||
return start >= 0 && end > start ? text[(start + 1)..end] : null;
|
||||
}
|
||||
|
||||
static void Rib(ReadOnlySpan<byte> rib, Dictionary<uint, Dictionary<uint, int>> counts)
|
||||
{
|
||||
if (rib.Length < 5) return;
|
||||
var at = 4;
|
||||
var bits = rib[at++];
|
||||
at += (bits + 7) / 8;
|
||||
if (at + 2 > rib.Length) return;
|
||||
var entries = BinaryPrimitives.ReadUInt16BigEndian(rib[at..]);
|
||||
at += 2;
|
||||
for (var e = 0; e < entries && at + 8 <= rib.Length; e++)
|
||||
{
|
||||
at += 6;
|
||||
var size = BinaryPrimitives.ReadUInt16BigEndian(rib[at..]);
|
||||
at += 2;
|
||||
if (at + size > rib.Length) return;
|
||||
Attributes(rib.Slice(at, size), counts);
|
||||
at += size;
|
||||
}
|
||||
}
|
||||
|
||||
static void Attributes(ReadOnlySpan<byte> attributes, Dictionary<uint, Dictionary<uint, int>> counts)
|
||||
{
|
||||
var at = 0;
|
||||
while (at + 3 <= attributes.Length)
|
||||
{
|
||||
var flags = attributes[at];
|
||||
var type = attributes[at + 1];
|
||||
var extended = (flags & 0x10) != 0;
|
||||
if (extended && at + 4 > attributes.Length) return;
|
||||
var length = extended ? BinaryPrimitives.ReadUInt16BigEndian(attributes[(at + 2)..]) : attributes[at + 2];
|
||||
var start = at + (extended ? 4 : 3);
|
||||
if (start + length > attributes.Length) return;
|
||||
if (type == 2)
|
||||
{
|
||||
Path(attributes.Slice(start, length), counts);
|
||||
return;
|
||||
}
|
||||
at = start + length;
|
||||
}
|
||||
}
|
||||
|
||||
static void Path(ReadOnlySpan<byte> path, Dictionary<uint, Dictionary<uint, int>> counts)
|
||||
{
|
||||
uint last = 0;
|
||||
uint before = 0;
|
||||
var endsInSet = false;
|
||||
var at = 0;
|
||||
while (at + 2 <= path.Length)
|
||||
{
|
||||
var segment = path[at];
|
||||
var count = path[at + 1];
|
||||
at += 2;
|
||||
if (at + count * 4 > path.Length) return;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var number = BinaryPrimitives.ReadUInt32BigEndian(path[(at + i * 4)..]);
|
||||
if (segment != 2)
|
||||
{
|
||||
endsInSet = true;
|
||||
continue;
|
||||
}
|
||||
endsInSet = false;
|
||||
if (number == last) continue;
|
||||
before = last;
|
||||
last = number;
|
||||
}
|
||||
at += count * 4;
|
||||
}
|
||||
if (endsInSet || last == 0 || before == 0 || Reserved(before) || Reserved(last)) return;
|
||||
if (!counts.TryGetValue(last, out var neighbours)) counts[last] = neighbours = [];
|
||||
neighbours[before] = neighbours.GetValueOrDefault(before) + 1;
|
||||
}
|
||||
|
||||
static bool Reserved(uint number) => number is 23456 or (>= 64496 and <= 131071) or >= 4200000000;
|
||||
|
||||
static bool Fill(Stream stream, Span<byte> buffer)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buffer.Length)
|
||||
{
|
||||
var n = stream.Read(buffer[read..]);
|
||||
if (n == 0) return false;
|
||||
read += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>MeWeb.Core</RootNamespace>
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
<KnowledgeFile>$(MSBuildThisFileDirectory)..\..\artifacts\knowledge\ip2asn.bin</KnowledgeFile>
|
||||
<KnowledgeFolder>$(MSBuildThisFileDirectory)..\..\artifacts\knowledge\</KnowledgeFolder>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="..\..\data\isps\*.json" LogicalName="isp.%(Filename).json" />
|
||||
<EmbeddedResource Include="$(KnowledgeFile)" LogicalName="ip2asn.bin" Condition="Exists('$(KnowledgeFile)')" />
|
||||
<EmbeddedResource Include="$(KnowledgeFolder)ip2asn.bin" LogicalName="ip2asn.bin" Condition="Exists('$(KnowledgeFolder)ip2asn.bin')" />
|
||||
<EmbeddedResource Include="$(KnowledgeFolder)networks.bin" LogicalName="networks.bin" Condition="Exists('$(KnowledgeFolder)networks.bin')" />
|
||||
<EmbeddedResource Include="$(KnowledgeFolder)carriers.json" LogicalName="carriers.json" Condition="Exists('$(KnowledgeFolder)carriers.json')" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -50,6 +50,15 @@ public class LocalSnapshot
|
||||
static bool IsV4(string text) => Net.Addresses.Parse(text)?.AddressFamily == AddressFamily.InterNetwork;
|
||||
}
|
||||
|
||||
public class CellularReport
|
||||
{
|
||||
public string Operator { get; set; }
|
||||
public string NetworkCode { get; set; }
|
||||
public string Carrier { get; set; }
|
||||
public bool? Roaming { get; set; }
|
||||
public string Radio { get; set; }
|
||||
}
|
||||
|
||||
public interface IPlatform
|
||||
{
|
||||
DeviceKind Device { get; }
|
||||
@@ -68,6 +77,8 @@ public interface IPlatform
|
||||
|
||||
LocalSnapshot ReadNetwork();
|
||||
|
||||
Task<CellularReport> ReadCellular(CancellationToken ct);
|
||||
|
||||
IDisposable AllowMulticast();
|
||||
}
|
||||
|
||||
@@ -133,6 +144,8 @@ public class SystemPlatform : IPlatform
|
||||
|
||||
public virtual IDisposable AllowMulticast() => new Nothing();
|
||||
|
||||
public virtual Task<CellularReport> ReadCellular(CancellationToken ct) => Task.FromResult<CellularReport>(null);
|
||||
|
||||
static readonly string[] Placeholders = ["o.e.m", "system product", "system manufacturer", "system version", "default string", "not applicable", "not specified", "to be filled", "none"];
|
||||
|
||||
static readonly Dictionary<string, string> Makers = new(StringComparer.OrdinalIgnoreCase)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
using MeWeb.Shared;
|
||||
|
||||
@@ -11,6 +13,7 @@ public class SpeedReport
|
||||
public int DownBytes { get; set; }
|
||||
public int UpBytes { get; set; }
|
||||
public bool Limited { get; set; }
|
||||
public int RoundTripMs { get; set; }
|
||||
}
|
||||
|
||||
public static class SpeedCheck
|
||||
@@ -19,6 +22,7 @@ public static class SpeedCheck
|
||||
{
|
||||
var mobile = kind == LinkKind.Mobile;
|
||||
var report = new SpeedReport { DownBytes = mobile ? 2 * 1024 * 1024 : 12 * 1024 * 1024, UpBytes = mobile ? 1024 * 1024 : 4 * 1024 * 1024 };
|
||||
report.RoundTripMs = await RoundTrip(ct);
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
@@ -66,5 +70,29 @@ public static class SpeedCheck
|
||||
return report;
|
||||
}
|
||||
|
||||
static async Task<int> RoundTrip(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var addresses = await Dns.GetHostAddressesAsync(Endpoints.Host, ct);
|
||||
if (addresses.Length == 0) return 0;
|
||||
var samples = new List<double>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
using var client = new TcpClient(addresses[0].AddressFamily);
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(3));
|
||||
var clock = Stopwatch.StartNew();
|
||||
await client.ConnectAsync(addresses[0], 443, timeout.Token);
|
||||
samples.Add(clock.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
return (int)Math.Round(samples.Order().ElementAt(samples.Count / 2));
|
||||
}
|
||||
catch (Exception) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static double Mbps(long bytes, TimeSpan elapsed) => elapsed.TotalSeconds <= 0 ? 0 : Math.Round(bytes * 8 / elapsed.TotalSeconds / 1_000_000, 1);
|
||||
}
|
||||
+159
-4
@@ -13,6 +13,31 @@ public enum NatBehaviour
|
||||
Hard,
|
||||
}
|
||||
|
||||
public enum PortPattern
|
||||
{
|
||||
Unknown,
|
||||
Kept,
|
||||
InOrder,
|
||||
Block,
|
||||
Scattered,
|
||||
}
|
||||
|
||||
public class PortReport
|
||||
{
|
||||
public string Address { get; set; }
|
||||
public List<int> Locals { get; set; } = [];
|
||||
public List<int> Mapped { get; set; } = [];
|
||||
public PortPattern Pattern { get; set; }
|
||||
public int Low { get; set; }
|
||||
public int High { get; set; }
|
||||
public int BlockLow { get; set; }
|
||||
public int BlockSize { get; set; }
|
||||
public bool? Hairpin { get; set; }
|
||||
public bool LifetimeDone { get; set; }
|
||||
public int KeptAtLeast { get; set; }
|
||||
public int LostBefore { get; set; }
|
||||
}
|
||||
|
||||
public class StunReport
|
||||
{
|
||||
public int LocalPort { get; set; }
|
||||
@@ -21,6 +46,7 @@ public class StunReport
|
||||
public string SecondServer { get; set; }
|
||||
public string SecondMapped { get; set; }
|
||||
public NatBehaviour Behaviour { get; set; }
|
||||
public PortReport Ports { get; set; }
|
||||
}
|
||||
|
||||
public static class Stun
|
||||
@@ -46,13 +72,142 @@ public static class Stun
|
||||
return report;
|
||||
}
|
||||
|
||||
static async Task<IPEndPoint> Binding(UdpClient udp, (string Host, int Port) server, CancellationToken ct)
|
||||
public static async Task<PortReport> Ports((string Host, int Port) server, int count, CancellationToken ct)
|
||||
{
|
||||
var report = new PortReport();
|
||||
var target = await Resolve(server, ct);
|
||||
if (target is null) return report;
|
||||
var sockets = new List<UdpClient>();
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < count; i++) sockets.Add(new UdpClient(new IPEndPoint(IPAddress.Any, 0)));
|
||||
var mapped = await Task.WhenAll(sockets.Select(s => Binding(s, target, ct)));
|
||||
for (var i = 0; i < sockets.Count; i++)
|
||||
{
|
||||
if (mapped[i] is null) continue;
|
||||
report.Address ??= mapped[i].Address.ToString();
|
||||
report.Locals.Add(((IPEndPoint)sockets[i].Client.LocalEndPoint).Port);
|
||||
report.Mapped.Add(mapped[i].Port);
|
||||
}
|
||||
Classify(report);
|
||||
var pair = Enumerable.Range(0, sockets.Count).Where(i => mapped[i] is not null).Take(2).ToList();
|
||||
if (pair.Count == 2) report.Hairpin = await Hairpin(sockets[pair[0]], mapped[pair[0]], sockets[pair[1]], mapped[pair[1]], ct);
|
||||
}
|
||||
catch (Exception) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var socket in sockets) socket.Dispose();
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
public static async Task Lifetime((string Host, int Port) server, PortReport report, int[] waits, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = await Resolve(server, ct);
|
||||
if (target is null) return;
|
||||
var outcomes = await Task.WhenAll(waits.Select(async wait =>
|
||||
{
|
||||
using var udp = new UdpClient(new IPEndPoint(IPAddress.Any, 0));
|
||||
var before = await Binding(udp, target, ct);
|
||||
if (before is null) return (wait, (bool?)null);
|
||||
await Task.Delay(TimeSpan.FromSeconds(wait), ct);
|
||||
var after = await Binding(udp, target, ct);
|
||||
return (wait, after is null ? null : after.Equals(before));
|
||||
}));
|
||||
report.KeptAtLeast = outcomes.Where(o => o.Item2 == true).Select(o => o.wait).DefaultIfEmpty(0).Max();
|
||||
report.LostBefore = outcomes.Where(o => o.Item2 == false).Select(o => o.wait).DefaultIfEmpty(0).Min();
|
||||
}
|
||||
catch (Exception) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
report.LifetimeDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Classify(PortReport report, int earlierLow = 0, int earlierHigh = 0)
|
||||
{
|
||||
var ports = report.Mapped;
|
||||
if (ports.Count < 4)
|
||||
{
|
||||
report.Pattern = PortPattern.Unknown;
|
||||
return;
|
||||
}
|
||||
var sorted = ports.Order().ToList();
|
||||
report.Low = sorted[0];
|
||||
report.High = sorted[^1];
|
||||
var kept = ports.Where((p, i) => p == report.Locals[i]).Count();
|
||||
var gaps = sorted.Zip(sorted.Skip(1), (a, b) => b - a).ToList();
|
||||
report.Pattern = kept * 5 >= ports.Count ? PortPattern.Kept
|
||||
: gaps.Max() < 50 ? PortPattern.InOrder
|
||||
: report.High - report.Low < 16384 ? PortPattern.Block
|
||||
: PortPattern.Scattered;
|
||||
if (report.Pattern is not (PortPattern.InOrder or PortPattern.Block)) return;
|
||||
var low = earlierLow > 0 ? Math.Min(earlierLow, report.Low) : report.Low;
|
||||
var high = earlierHigh > 0 ? Math.Max(earlierHigh, report.High) : report.High;
|
||||
var size = 1;
|
||||
while (size < 65536 && (low - low % size) + size - 1 < high) size *= 2;
|
||||
report.BlockSize = size;
|
||||
report.BlockLow = low - low % size;
|
||||
}
|
||||
|
||||
static async Task<bool?> Hairpin(UdpClient a, IPEndPoint mappedA, UdpClient b, IPEndPoint mappedB, CancellationToken ct)
|
||||
{
|
||||
var token = RandomNumberGenerator.GetBytes(16);
|
||||
using var window = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
window.CancelAfter(1500);
|
||||
var heardA = Hear(a, token, window.Token);
|
||||
var heardB = Hear(b, token, window.Token);
|
||||
await b.SendAsync(token, mappedA, ct);
|
||||
await a.SendAsync(token, mappedB, ct);
|
||||
var heard = await Task.WhenAll(heardA, heardB);
|
||||
return heard.Any(h => h);
|
||||
}
|
||||
|
||||
static async Task<bool> Hear(UdpClient udp, byte[] token, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var received = await udp.ReceiveAsync(ct);
|
||||
if (received.Buffer.AsSpan().SequenceEqual(token)) return true;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<IPEndPoint> Resolve((string Host, int Port) server, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var addresses = await Dns.GetHostAddressesAsync(server.Host, AddressFamily.InterNetwork, ct);
|
||||
if (addresses.Length == 0) return null;
|
||||
var target = new IPEndPoint(addresses[0], server.Port);
|
||||
return addresses.Length == 0 ? null : new IPEndPoint(addresses[0], server.Port);
|
||||
}
|
||||
catch (Exception) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<IPEndPoint> Binding(UdpClient udp, (string Host, int Port) server, CancellationToken ct)
|
||||
{
|
||||
var target = await Resolve(server, ct);
|
||||
return target is null ? null : await Binding(udp, target, ct);
|
||||
}
|
||||
|
||||
static async Task<IPEndPoint> Binding(UdpClient udp, IPEndPoint target, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new byte[20];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(request, 0x0001);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(request.AsSpan(4), Cookie);
|
||||
@@ -77,7 +232,7 @@ public static class Stun
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (SocketException)
|
||||
catch (Exception) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user