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:
thepraandClaude Opus 5.5 committed 2026-10-15 04:39:03 +02:00
1 parent 16a1cecaae
commit 0dffbc27d6
61 files changed
+3750 -149

No files matched your search

+126
View File
@@ -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;
}
}
+70
View File
@@ -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;
}
}
+120
View File
@@ -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);
}
}
}
+255
View File
@@ -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..]));
}