using System.Globalization; using MeWeb.Core.History; using MeWeb.Core.Knowledge; using MeWeb.Core.Net; using MeWeb.Core.Router; using MeWeb.Shared; 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 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 Behaviour(StunReport stun) { var finding = new Finding { Id = CheckId.Behaviour }; (finding.Outcome, finding.Summary) = stun.Behaviour switch { 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); 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"); 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; } public static Finding Speed(SpeedReport speed) { 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)); 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; } 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..])); }