2026-10-15 04:39:03 +02:00
|
|
|
using System.Globalization;
|
|
|
|
|
|
|
|
|
|
using MeWeb.Core.History;
|
|
|
|
|
using MeWeb.Core.Knowledge;
|
|
|
|
|
using MeWeb.Core.Net;
|
|
|
|
|
using MeWeb.Core.Router;
|
2026-10-15 05:41:44 +02:00
|
|
|
using MeWeb.Shared;
|
2026-10-15 04:39:03 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 11:11:24 +02:00
|
|
|
public static Finding Connection(LocalSnapshot local, CellularReport cellular, KnowledgeBase knowledge)
|
2026-10-15 04:39:03 +02:00
|
|
|
{
|
|
|
|
|
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)));
|
2026-10-15 11:11:24 +02:00
|
|
|
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");
|
2026-10-15 04:39:03 +02:00
|
|
|
return finding;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 12:59:42 +02:00
|
|
|
public static Finding Addresses(string v4, string v6, LocalSnapshot local, RouterReport router)
|
2026-10-15 04:39:03 +02:00
|
|
|
{
|
|
|
|
|
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);
|
2026-10-15 12:59:42 +02:00
|
|
|
finding.WithKey("FactWhoHasIt", Holder(NatOf(local, router, v4)));
|
2026-10-15 04:39:03 +02:00
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 12:59:42 +02:00
|
|
|
public static Finding Provider(KnowledgeBase knowledge, AsnEntry v4, AsnEntry v6, bool anyAddress, bool mobile, RunReport run = null)
|
2026-10-15 04:39:03 +02:00
|
|
|
{
|
|
|
|
|
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);
|
2026-10-15 19:44:18 +02:00
|
|
|
var observed = knowledge.ObservedFor(main.Number, mobile);
|
|
|
|
|
var hand = mobile && observed is not null ? null : profile;
|
2026-10-15 04:39:03 +02:00
|
|
|
finding.Outcome = Outcome.Info;
|
2026-10-15 19:44:18 +02:00
|
|
|
finding.Summary = profile is null && observed is null ? "ProviderUnlisted" : mobile && observed is null ? "ProviderListedMobile" : "ProviderListed";
|
2026-10-15 04:39:03 +02:00
|
|
|
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));
|
2026-10-15 11:11:24 +02:00
|
|
|
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})")));
|
2026-10-15 19:44:18 +02:00
|
|
|
if (hand is null && observed is null) return finding;
|
|
|
|
|
var usual = AddFact(finding, "FactProviderIpv4", "ProviderIpv4", hand?.Ipv4, observed?.Ipv4, observed?.To);
|
2026-10-15 12:59:42 +02:00
|
|
|
var sharedHere = run?.Nat is NatKind.Shared or NatKind.Translated;
|
|
|
|
|
var ownHere = run?.Nat is NatKind.HomeRouter or NatKind.Direct or NatKind.DoubleNat;
|
2026-10-15 19:44:18 +02:00
|
|
|
if ((usual is "Shared" or "SharedPorts" && ownHere) || (usual is "PublicDynamic" or "Public" && sharedHere))
|
2026-10-15 12:59:42 +02:00
|
|
|
finding.WithKey("FactThisLine", Holder(run.Nat));
|
2026-10-15 19:44:18 +02:00
|
|
|
AddFact(finding, "FactProviderPublic", "ProviderPublic", hand?.PublicAddress, null, null);
|
|
|
|
|
AddFact(finding, "FactProviderIpv6", "ProviderIpv6", hand?.Ipv6, observed?.Ipv6, observed?.To);
|
|
|
|
|
var port25 = AddFact(finding, "FactProviderPort25", "ProviderPort25", hand?.Port25, observed?.Port25, observed?.To);
|
2026-10-15 12:59:42 +02:00
|
|
|
if (run?.Mail?.Port25 == Core.Mail.PortState.Open && port25 is "Blocked" or "BlockedOnRequest" or "Closed")
|
|
|
|
|
finding.WithKey("FactThisLine", "ValuePort25Open", run.Mail.Banner ?? "");
|
|
|
|
|
else if (run?.Mail?.Port25 == Core.Mail.PortState.Blocked && port25 is "Open")
|
|
|
|
|
finding.WithKey("FactThisLine", "ValuePort25Blocked");
|
2026-10-15 19:44:18 +02:00
|
|
|
if (hand?.Sheet is not null) finding.Facts.Add(new Fact { Label = "FactProviderSheet", Key = "ValueOpenSheet", Link = hand.Sheet });
|
2026-10-15 04:39:03 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 12:59:42 +02:00
|
|
|
static string Holder(NatKind nat) => nat switch
|
|
|
|
|
{
|
|
|
|
|
NatKind.Direct => "ValueOnThisDevice",
|
|
|
|
|
NatKind.Shared or NatKind.Translated => "ValueSharedProvider",
|
|
|
|
|
_ => "ValueSharedAtHome",
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-15 04:39:03 +02:00
|
|
|
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);
|
2026-10-15 09:10:37 +02:00
|
|
|
if (wan is null && localV4 is not null && Net.Addresses.Classify(localV4) == AddressKind.Shared) return NatKind.Shared;
|
2026-10-15 04:39:03 +02:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 12:59:42 +02:00
|
|
|
public static Finding Behaviour(StunReport stun, NatKind nat, bool ownNetwork)
|
2026-10-15 05:41:44 +02:00
|
|
|
{
|
|
|
|
|
var finding = new Finding { Id = CheckId.Behaviour };
|
|
|
|
|
(finding.Outcome, finding.Summary) = stun.Behaviour switch
|
|
|
|
|
{
|
2026-10-15 12:59:42 +02:00
|
|
|
_ when ownNetwork => (Outcome.Unknown, "ReachOwnNetwork"),
|
2026-10-15 05:41:44 +02:00
|
|
|
NatBehaviour.None => (Outcome.Good, "BehaviourNone"),
|
|
|
|
|
NatBehaviour.Easy => (Outcome.Good, "BehaviourEasy"),
|
|
|
|
|
NatBehaviour.Hard => (Outcome.Caution, "BehaviourHard"),
|
|
|
|
|
_ => (Outcome.Unknown, "BehaviourUnknown"),
|
|
|
|
|
};
|
|
|
|
|
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);
|
2026-10-15 11:11:24 +02:00
|
|
|
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;
|
2026-10-15 05:41:44 +02:00
|
|
|
return finding;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static Finding Reach(ReachReport reach, RunReport run)
|
|
|
|
|
{
|
|
|
|
|
var finding = new Finding { Id = CheckId.Reach };
|
|
|
|
|
if (reach is null || reach.Choice == ReachChoice.Skip)
|
|
|
|
|
{
|
|
|
|
|
finding.Outcome = Outcome.Unknown;
|
|
|
|
|
finding.Summary = "ReachSkipped";
|
|
|
|
|
return finding;
|
|
|
|
|
}
|
|
|
|
|
var refusal = reach.V4Refusal ?? reach.V6Refusal;
|
|
|
|
|
if (reach.V4 is null && reach.V6 is null)
|
|
|
|
|
{
|
|
|
|
|
finding.Outcome = Outcome.Unknown;
|
|
|
|
|
finding.Summary = refusal switch
|
|
|
|
|
{
|
|
|
|
|
"own-network" => "ReachOwnNetwork",
|
|
|
|
|
"too-many" => "ReachTooMany",
|
|
|
|
|
"no-listener" => "ReachNoListener",
|
|
|
|
|
_ => "ReachUnavailable",
|
|
|
|
|
};
|
|
|
|
|
if (refusal == "too-many") finding.Args = [Math.Max(1, (int)Math.Ceiling(reach.RetryAfterSeconds / 60.0)).ToString(CultureInfo.InvariantCulture)];
|
|
|
|
|
return finding;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var reached4 = reach.V4?.Ports.Any(p => p.Outcome == ProbeOutcome.Reached) == true;
|
|
|
|
|
var reached6 = reach.V6?.Ports.Any(p => p.Outcome == ProbeOutcome.Reached) == true;
|
|
|
|
|
var mapped = reach.Mappings.Any(m => m.Done);
|
|
|
|
|
(finding.Outcome, finding.Summary) = (reached4, reached6) switch
|
|
|
|
|
{
|
|
|
|
|
(true, true) => (Outcome.Good, "ReachBoth"),
|
|
|
|
|
(true, false) => (Outcome.Good, "ReachV4"),
|
|
|
|
|
(false, true) => (Outcome.Caution, "ReachV6Only"),
|
|
|
|
|
_ when reach.Choice == ReachChoice.OpenPorts && mapped => (Outcome.Bad, "ReachBlockedAfterMapping"),
|
|
|
|
|
_ when reach.Choice == ReachChoice.OpenPorts => (Outcome.Bad, "ReachNoMapping"),
|
|
|
|
|
_ => (Outcome.Caution, "ReachNothingAsIs"),
|
|
|
|
|
};
|
|
|
|
|
foreach (var attempt in reach.Mappings)
|
|
|
|
|
finding.WithKey("FactMapping", attempt.Done ? "ValueMapped" : "ValueNotMapped", attempt.ExternalPort.ToString(CultureInfo.InvariantCulture), attempt.Via.ToString(), attempt.Error.ToString(CultureInfo.InvariantCulture));
|
|
|
|
|
foreach (var port in reach.V4?.Ports ?? [])
|
|
|
|
|
finding.WithKey("FactReachV4", "Probe" + port.Outcome, port.Port.ToString(CultureInfo.InvariantCulture));
|
|
|
|
|
foreach (var port in reach.V6?.Ports ?? [])
|
|
|
|
|
finding.WithKey("FactReachV6", "Probe" + port.Outcome, port.Port.ToString(CultureInfo.InvariantCulture));
|
|
|
|
|
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");
|
2026-10-15 11:11:24 +02:00
|
|
|
var route = reach.V4?.Route ?? reach.V6?.Route ?? [];
|
2026-10-15 19:44:18 +02:00
|
|
|
var site = run?.Site ?? Endpoints.Main;
|
|
|
|
|
if (route.Count > 0) finding.With(site == Endpoints.Main ? "FactRouteBack" : "FactRouteBackNode", string.Join(" → ", route.Select(h => h.Name ?? h.Address ?? "*")));
|
|
|
|
|
if (Endpoints.Nodes.Count > 0) finding.With("FactMeasuredFrom", $"{site.Place} ({site.Host})");
|
2026-10-15 05:41:44 +02:00
|
|
|
if (reach.V4?.Ports.Any(p => p.Outcome == ProbeOutcome.OpenNotYou && p.Port is 80 or 443) == true && !mapped)
|
|
|
|
|
finding.WithKey("FactHint", "HintSomethingAnswers");
|
|
|
|
|
return finding;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static Finding Mail(Mail.MailReport mail, bool hasV4)
|
|
|
|
|
{
|
|
|
|
|
var finding = new Finding { Id = CheckId.Mail };
|
|
|
|
|
if (!hasV4)
|
|
|
|
|
{
|
|
|
|
|
finding.Outcome = Outcome.Unknown;
|
|
|
|
|
finding.Summary = "MailNoIpv4";
|
|
|
|
|
return finding;
|
|
|
|
|
}
|
|
|
|
|
var listed = mail.Listings.Where(l => l.State == Core.Mail.ListingState.Listed && !l.Residential && !l.Wide).ToList();
|
|
|
|
|
var wide = mail.Listings.Where(l => l.State == Core.Mail.ListingState.Listed && l.Wide).ToList();
|
|
|
|
|
(finding.Outcome, finding.Summary) = mail switch
|
|
|
|
|
{
|
|
|
|
|
{ Port25: Core.Mail.PortState.Blocked } => (Outcome.Bad, "MailBlocked"),
|
|
|
|
|
_ when listed.Count > 0 => (Outcome.Bad, "MailListed"),
|
|
|
|
|
_ when mail.Listings.Any(l => l.Residential) => (Outcome.Caution, "MailResidential"),
|
|
|
|
|
_ when wide.Count > 0 => (Outcome.Caution, "MailNetworkListed"),
|
|
|
|
|
{ ReverseName: null } => (Outcome.Caution, "MailNoName"),
|
|
|
|
|
{ ReverseLooksDynamic: true } => (Outcome.Caution, "MailDynamicName"),
|
|
|
|
|
{ Port25: Core.Mail.PortState.Open } => (Outcome.Good, "MailClean"),
|
|
|
|
|
_ => (Outcome.Unknown, "MailUnknown"),
|
|
|
|
|
};
|
|
|
|
|
if (listed.Count > 0) finding.Args = [string.Join(", ", listed.Select(l => l.List))];
|
|
|
|
|
else if (wide.Count > 0) finding.Args = [string.Join(", ", wide.Select(l => l.List))];
|
|
|
|
|
finding.WithKey("FactPort25", mail.Port25 switch
|
|
|
|
|
{
|
|
|
|
|
Core.Mail.PortState.Open => "ValuePort25Open",
|
|
|
|
|
Core.Mail.PortState.Blocked => "ValuePort25Blocked",
|
|
|
|
|
_ => "ValueCantTell",
|
|
|
|
|
}, mail.Banner ?? "");
|
|
|
|
|
if (mail.ReverseName is null) finding.WithKey("FactReverseName", "ValueNoReverse");
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
finding.With("FactReverseName", mail.ReverseName);
|
|
|
|
|
finding.WithKey("FactReverseLooks", mail.ReverseLooksDynamic ? "ValueLooksHome" : "ValueLooksServer");
|
|
|
|
|
finding.WithKey("FactReverseConfirmed", mail.ReverseConfirmed ? "ValueYes" : "ValueNo");
|
|
|
|
|
}
|
|
|
|
|
foreach (var listing in mail.Listings)
|
|
|
|
|
finding.Facts.Add(new Fact
|
|
|
|
|
{
|
|
|
|
|
Label = "FactList",
|
|
|
|
|
Key = listing.State switch
|
|
|
|
|
{
|
|
|
|
|
Core.Mail.ListingState.Listed when listing.Residential => "ValueListedHome",
|
|
|
|
|
Core.Mail.ListingState.Listed when listing.Wide => "ValueListedNetwork",
|
|
|
|
|
Core.Mail.ListingState.Listed => "ValueListed",
|
|
|
|
|
Core.Mail.ListingState.Clean => "ValueNotListed",
|
|
|
|
|
_ => "ValueCantTellResolver",
|
|
|
|
|
},
|
|
|
|
|
Args = [listing.List, listing.Code ?? ""],
|
|
|
|
|
});
|
|
|
|
|
return finding;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static Finding Dns(DnsReport dns)
|
|
|
|
|
{
|
|
|
|
|
var finding = new Finding { Id = CheckId.Dns };
|
|
|
|
|
(finding.Outcome, finding.Summary) = dns switch
|
|
|
|
|
{
|
|
|
|
|
{ Hijacks: true } => (Outcome.Bad, "DnsHijack"),
|
|
|
|
|
{ Validates: false } => (Outcome.Caution, "DnsNoValidation"),
|
|
|
|
|
{ Validates: true, Hijacks: false } => (Outcome.Good, "DnsGood"),
|
|
|
|
|
_ => (Outcome.Unknown, "DnsUnknown"),
|
|
|
|
|
};
|
|
|
|
|
if (dns.Resolvers.Count > 0) finding.With("FactDns", string.Join(", ", dns.Resolvers.Take(3)));
|
|
|
|
|
if (dns.Validates is not null) finding.WithKey("FactDnssec", dns.Validates == true ? "ValueDnssecYes" : "ValueDnssecNo");
|
|
|
|
|
if (dns.Hijacks is not null) finding.WithKey("FactMissingNames", dns.Hijacks == true ? "ValueHijacked" : "ValueAnsweredMissing");
|
|
|
|
|
if (dns.Ipv6Names is not null) finding.WithKey("FactIpv6Names", dns.Ipv6Names == true ? "ValueYes" : "ValueNo");
|
|
|
|
|
return finding;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 19:44:18 +02:00
|
|
|
public static Finding Speed(SpeedReport speed, MeasuringSite site)
|
2026-10-15 05:41:44 +02:00
|
|
|
{
|
|
|
|
|
var finding = new Finding { Id = CheckId.Speed };
|
|
|
|
|
if (speed.Limited)
|
|
|
|
|
{
|
|
|
|
|
finding.Outcome = Outcome.Unknown;
|
|
|
|
|
finding.Summary = "SpeedLimited";
|
|
|
|
|
return finding;
|
|
|
|
|
}
|
|
|
|
|
if (speed.DownMbps <= 0)
|
|
|
|
|
{
|
|
|
|
|
finding.Outcome = Outcome.Unknown;
|
|
|
|
|
finding.Summary = "SpeedFailed";
|
|
|
|
|
return finding;
|
|
|
|
|
}
|
|
|
|
|
finding.Outcome = Outcome.Info;
|
|
|
|
|
finding.Summary = "SpeedResult";
|
|
|
|
|
finding.Args = [speed.DownMbps.ToString("0.#", CultureInfo.InvariantCulture), speed.UpMbps.ToString("0.#", CultureInfo.InvariantCulture)];
|
|
|
|
|
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));
|
2026-10-15 19:44:18 +02:00
|
|
|
if (speed.RoundTripMs > 0) finding.WithKey(site == Endpoints.Main ? "FactRoundTrip" : "FactRoundTripNode", "ValueMilliseconds", speed.RoundTripMs.ToString(CultureInfo.InvariantCulture));
|
|
|
|
|
if (Endpoints.Nodes.Count > 0) finding.With("FactMeasuredFrom", $"{site.Place} ({site.Host})");
|
2026-10-15 05:41:44 +02:00
|
|
|
return finding;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static Finding Device(DeviceReport device)
|
|
|
|
|
{
|
|
|
|
|
var finding = new Finding { Id = CheckId.Device };
|
|
|
|
|
var small = device.Cores < 2 || device.MemoryMb < 1800 || device.FreeDiskGb is > 0 and < 20;
|
|
|
|
|
(finding.Outcome, finding.Summary) = device switch
|
|
|
|
|
{
|
|
|
|
|
{ Kind: DeviceKind.Phone } => (Outcome.Caution, "DevicePhone"),
|
|
|
|
|
_ when small => (Outcome.Caution, "DeviceSmall"),
|
|
|
|
|
{ OnBattery: true } => (Outcome.Info, "DeviceLaptop"),
|
|
|
|
|
_ => (Outcome.Good, "DeviceGood"),
|
|
|
|
|
};
|
|
|
|
|
finding.With("FactSystem", device.System);
|
|
|
|
|
finding.WithKey("FactCores", "ValueCount", device.Cores.ToString(CultureInfo.InvariantCulture));
|
|
|
|
|
finding.WithKey("FactMemory", "ValueGb", Math.Round(device.MemoryMb / 1024.0, 1).ToString("0.#", CultureInfo.InvariantCulture));
|
|
|
|
|
if (device.FreeDiskGb > 0) finding.WithKey("FactDisk", "ValueGbFree", device.FreeDiskGb.ToString(CultureInfo.InvariantCulture));
|
|
|
|
|
if (device.OnBattery is not null) finding.WithKey("FactBattery", device.OnBattery == true ? "ValueYes" : "ValueNo");
|
|
|
|
|
return finding;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 04:39:03 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 19:44:18 +02:00
|
|
|
static string AddFact(Finding finding, string label, string prefix, ProviderFact fact, ObservedFact seen, string month)
|
2026-10-15 04:39:03 +02:00
|
|
|
{
|
2026-10-15 19:44:18 +02:00
|
|
|
if (fact?.Value is not null)
|
2026-10-15 04:39:03 +02:00
|
|
|
{
|
2026-10-15 19:44:18 +02:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
return Pascal(fact.Value);
|
|
|
|
|
}
|
|
|
|
|
if (seen?.Value is null) return null;
|
|
|
|
|
finding.Facts.Add(new Fact { Label = label, Key = prefix + Pascal(seen.Value), Basis = "BasisChecks", Agree = seen.Agree, Of = seen.Of, Date = month });
|
|
|
|
|
return Pascal(seen.Value);
|
2026-10-15 04:39:03 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-10-15 09:31:40 +02:00
|
|
|
static string Describe(AsnEntry entry) => $"{Display(entry)} (AS{entry.Number})";
|
2026-10-15 04:39:03 +02:00
|
|
|
|
|
|
|
|
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..]));
|
|
|
|
|
}
|