me-web? 0.2: the test from outside, calls and games, mail, and what you can host
Phases two and three ship together, because their code grew in the same files. With consent the app asks the router to open 80, 443 and a high port for ten minutes (UPnP, NAT-PMP or PCP), and the server, holding a single-use ticket bound to the caller's own address, connects back and must get an HMAC only the app can give; the mappings are removed after the run and remembered until then, so a crash cannot leave one open. Hairpin is tried from inside. Two STUN servers, the outside one first, tell easy from hard NAT. Mail gets port 25 to Gmail and Outlook, the reverse name and five blocklists through the user's own resolver, network-wide UCEPROTECT listings counted as a caution. DNS, speed against the server (four at a time, twelve an hour per address) and the device follow, and six uses (website, home from outside, files, game server, fediverse, mail) close the run as possible, possible with a named workaround, or can't tell. Findings can be shared after reading the exact JSON: a file per submission under the unit's state directory, ten a day per address, deletable by the token the app keeps, swept after twelve months; /privacy says all of it in English and Italian. QR codes lead to the downloads. Every language carries every string. 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
f79682eaef
commit
7cfc8b6de5
161 files changed
+23159
-305
No files matched your search
@@ -19,16 +19,23 @@ public class RunReport
|
||||
public RouterReport Router { get; set; }
|
||||
public List<Hop> Hops { get; set; } = [];
|
||||
public NatKind Nat { get; set; }
|
||||
public StunReport Stun { get; set; }
|
||||
public ReachReport Reach { get; set; }
|
||||
public Mail.MailReport Mail { get; set; }
|
||||
public DnsReport Dns { get; set; }
|
||||
public DeviceReport DeviceReport { get; set; }
|
||||
public SpeedReport Speed { get; set; }
|
||||
public List<UseFinding> Uses { 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 class CheckRunner(IPlatform platform, ServerProbe server, KnowledgeBase knowledge, HistoryStore history, HttpClient https, string folder)
|
||||
{
|
||||
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);
|
||||
foreach (var id in Enum.GetValues<CheckId>().Where(i => i != CheckId.Reach)) report(Finding.Running(id), run);
|
||||
|
||||
void Settle(Finding finding)
|
||||
{
|
||||
@@ -105,9 +112,49 @@ public class CheckRunner(IPlatform platform, ServerProbe server, KnowledgeBase k
|
||||
Settle(finding);
|
||||
});
|
||||
|
||||
var behaviour = Step(CheckId.Behaviour, async () =>
|
||||
{
|
||||
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));
|
||||
});
|
||||
|
||||
var mail = Step(CheckId.Mail, async () =>
|
||||
{
|
||||
await addresses;
|
||||
run.Mail = await Mail.MailChecks.Run(run.PublicV4, ct);
|
||||
Settle(Verdicts.Mail(run.Mail, run.PublicV4 is not null));
|
||||
});
|
||||
|
||||
var names = Step(CheckId.Dns, async () =>
|
||||
{
|
||||
await connection;
|
||||
run.Dns = await DnsChecks.Run(run.Local, ct);
|
||||
Settle(Verdicts.Dns(run.Dns));
|
||||
});
|
||||
|
||||
var device = Step(CheckId.Device, () =>
|
||||
{
|
||||
run.DeviceReport = DeviceCheck.Read(platform, folder);
|
||||
Settle(Verdicts.Device(run.DeviceReport));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var speed = Step(CheckId.Speed, async () =>
|
||||
{
|
||||
await Task.WhenAll(onlineDone, nat, behaviour, mail, names);
|
||||
if (run.Online?.State is not (OnlineState.Online or OnlineState.Rewritten))
|
||||
{
|
||||
Settle(new Finding { Id = CheckId.Speed, Outcome = Outcome.Unknown, Summary = "SpeedOffline" });
|
||||
return;
|
||||
}
|
||||
run.Speed = await SpeedCheck.Run(https, run.Local?.Active?.Kind ?? LinkKind.Unknown, ct);
|
||||
Settle(Verdicts.Speed(run.Speed));
|
||||
});
|
||||
|
||||
await Step(CheckId.History, async () =>
|
||||
{
|
||||
await Task.WhenAll(onlineDone, provider, nat);
|
||||
await Task.WhenAll(onlineDone, provider, nat, behaviour, mail, names, device, speed);
|
||||
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
|
||||
@@ -120,6 +167,7 @@ public class CheckRunner(IPlatform platform, ServerProbe server, KnowledgeBase k
|
||||
Nat = run.Nat.ToString(),
|
||||
});
|
||||
Settle(Verdicts.History(run.History));
|
||||
run.Uses = Uses.Decide(run);
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ public enum CheckId
|
||||
Provider,
|
||||
Router,
|
||||
Nat,
|
||||
Behaviour,
|
||||
Reach,
|
||||
Mail,
|
||||
Dns,
|
||||
Speed,
|
||||
Device,
|
||||
History,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
using MeWeb.Core.Net;
|
||||
using MeWeb.Core.Router;
|
||||
using MeWeb.Shared;
|
||||
|
||||
namespace MeWeb.Core.Checks;
|
||||
|
||||
public enum ReachChoice
|
||||
{
|
||||
OpenPorts,
|
||||
AsIs,
|
||||
Skip,
|
||||
}
|
||||
|
||||
public class ReachReport
|
||||
{
|
||||
public ReachChoice Choice { get; set; }
|
||||
public int ListenPort { get; set; }
|
||||
public List<MappingAttempt> Mappings { get; set; } = [];
|
||||
public ViewProbe V4 { get; set; }
|
||||
public ViewProbe V6 { get; set; }
|
||||
public string V4Refusal { get; set; }
|
||||
public string V6Refusal { get; set; }
|
||||
public int RetryAfterSeconds { get; set; }
|
||||
public bool? Hairpin { get; set; }
|
||||
}
|
||||
|
||||
public class Reach(ServerProbe server, Leftovers leftovers)
|
||||
{
|
||||
static readonly int[] Wellknown = [80, 443];
|
||||
|
||||
public async Task<ReachReport> Run(RunReport run, ReachChoice choice, CancellationToken ct)
|
||||
{
|
||||
var report = new ReachReport { Choice = choice };
|
||||
if (choice == ReachChoice.Skip) return report;
|
||||
|
||||
var ticket4 = run.PublicV4 is null ? (null, null) : await server.Ticket(false, ct);
|
||||
var ticket6 = run.PublicV6 is null ? (null, null) : await server.Ticket(true, ct);
|
||||
report.V4Refusal = ticket4.Refusal?.Reason;
|
||||
report.V6Refusal = ticket6.Refusal?.Reason;
|
||||
report.RetryAfterSeconds = Math.Max(ticket4.Refusal?.RetryAfterSeconds ?? 0, ticket6.Refusal?.RetryAfterSeconds ?? 0);
|
||||
if (ticket4.Ticket is null && ticket6.Ticket is null) return report;
|
||||
|
||||
Answerer started;
|
||||
try
|
||||
{
|
||||
started = Answerer.Start();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
report.V4Refusal ??= "no-listener";
|
||||
report.V6Refusal ??= "no-listener";
|
||||
report.V4 = null;
|
||||
report.V6 = null;
|
||||
return report;
|
||||
}
|
||||
await using var answerer = started;
|
||||
report.ListenPort = answerer.Port;
|
||||
if (ticket4.Ticket is not null) answerer.Accept(ticket4.Ticket.Nonce, Convert.FromBase64String(ticket4.Ticket.Key));
|
||||
if (ticket6.Ticket is not null) answerer.Accept(ticket6.Ticket.Nonce, Convert.FromBase64String(ticket6.Ticket.Key));
|
||||
|
||||
try
|
||||
{
|
||||
var local = run.Local?.LocalV4;
|
||||
if (choice == ReachChoice.OpenPorts && ticket4.Ticket is not null && run.Router?.AnyAnswer == true && local is not null)
|
||||
{
|
||||
foreach (var external in Wellknown.Append(answerer.Port))
|
||||
{
|
||||
var attempt = await PortMapper.Map(run.Router, local, external, answerer.Port, ct);
|
||||
if (attempt.Done) leftovers.Remember(attempt.Mapping);
|
||||
report.Mappings.Add(attempt);
|
||||
}
|
||||
}
|
||||
|
||||
var ports4 = report.Mappings.Count > 0
|
||||
? report.Mappings.Select(m => m.Mapping?.ExternalPort ?? m.ExternalPort).ToList()
|
||||
: [.. Wellknown, answerer.Port];
|
||||
ports4 = ports4.Where(p => p is 80 or 443 or (>= 49152 and <= 65535)).Distinct().Take(3).ToList();
|
||||
|
||||
var knock4 = ticket4.Ticket is null ? Task.FromResult<(ViewProbe, ViewRefusal)>((null, null)) : server.Knock(false, ticket4.Ticket.Id, ports4, ct);
|
||||
var knock6 = ticket6.Ticket is null ? Task.FromResult<(ViewProbe, ViewRefusal)>((null, null)) : server.Knock(true, ticket6.Ticket.Id, [answerer.Port], ct);
|
||||
var (probe4, refusal4) = await knock4;
|
||||
var (probe6, refusal6) = await knock6;
|
||||
report.V4 = probe4;
|
||||
report.V6 = probe6;
|
||||
report.V4Refusal ??= refusal4?.Reason;
|
||||
report.V6Refusal ??= refusal6?.Reason;
|
||||
|
||||
var own = report.Mappings.FirstOrDefault(m => m.Mapping?.InternalPort == answerer.Port && m.ExternalPort == answerer.Port)?.Mapping;
|
||||
if (own is not null && ticket4.Ticket is not null && Addresses.Parse(run.PublicV4) is { } publicV4)
|
||||
report.Hairpin = await Knock(publicV4, own.ExternalPort, ticket4.Ticket, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var mapping in report.Mappings.Where(m => m.Done).Select(m => m.Mapping))
|
||||
{
|
||||
await PortMapper.Remove(mapping, CancellationToken.None);
|
||||
leftovers.Forget(mapping);
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
static async Task<bool> Knock(IPAddress address, int port, ViewProbeTicket ticket, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient(address.AddressFamily);
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(4));
|
||||
await client.ConnectAsync(address, port, timeout.Token);
|
||||
var stream = client.GetStream();
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes($"{ProbeProof.Greeting} {ticket.Nonce} {port}\n"), timeout.Token);
|
||||
var buffer = new byte[96];
|
||||
var read = await stream.ReadAsync(buffer, timeout.Token);
|
||||
return Encoding.ASCII.GetString(buffer, 0, read).Trim() == ProbeProof.For(Convert.FromBase64String(ticket.Key), ticket.Nonce, port);
|
||||
}
|
||||
catch (Exception ex) when (ex is SocketException or IOException or OperationCanceledException && !ct.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,64 @@ public class ServerProbe(HttpClient https, HttpClient plain)
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(ViewProbeTicket Ticket, ViewRefusal Refusal)> Ticket(bool v6, CancellationToken ct)
|
||||
{
|
||||
var origin = v6 ? Endpoints.BaseV6 : Endpoints.BaseV4;
|
||||
return await Send(HttpMethod.Post, origin + Endpoints.Probe.POST_Ticket, null, MeWebJsonContext.Default.ViewProbeTicket, ct);
|
||||
}
|
||||
|
||||
public async Task<(ViewProbe Probe, ViewRefusal Refusal)> Knock(bool v6, string id, List<int> ports, CancellationToken ct)
|
||||
{
|
||||
var origin = v6 ? Endpoints.BaseV6 : Endpoints.BaseV4;
|
||||
var form = JsonContent.Create(new ProbeForm { Ports = ports }, MeWebJsonContext.Default.ProbeForm);
|
||||
return await Send(HttpMethod.Post, origin + Endpoints.Probe.POST_M_Run(id), form, MeWebJsonContext.Default.ViewProbe, ct, TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
public async Task<(ViewShared Shared, ViewRefusal Refusal)> Share(string json, CancellationToken ct)
|
||||
{
|
||||
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
return await Send(HttpMethod.Post, Endpoints.Base + Endpoints.Share.POST_Create, content, MeWebJsonContext.Default.ViewShared, ct);
|
||||
}
|
||||
|
||||
public async Task<bool> Unshare(string id, string token, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, Endpoints.Base + Endpoints.Share.DELETE_M_One(id));
|
||||
request.Headers.TryAddWithoutValidation(DeleteTokenHeader, token);
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(Patience);
|
||||
using var response = await https.SendAsync(request, timeout.Token);
|
||||
return response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NotFound;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public const string DeleteTokenHeader = "X-Delete-Token";
|
||||
|
||||
async Task<(T Value, ViewRefusal Refusal)> Send<T>(HttpMethod method, string url, HttpContent content, System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> type, CancellationToken ct, TimeSpan? patience = null) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, url) { Content = content };
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(patience ?? Patience);
|
||||
using var response = await https.SendAsync(request, timeout.Token);
|
||||
if (response.IsSuccessStatusCode) return (await response.Content.ReadFromJsonAsync(type, timeout.Token), null);
|
||||
var refusal = response.Content.Headers.ContentType?.MediaType == "application/json"
|
||||
? await response.Content.ReadFromJsonAsync(MeWebJsonContext.Default.ViewRefusal, timeout.Token)
|
||||
: null;
|
||||
return (null, refusal ?? new ViewRefusal { Reason = $"http-{(int)response.StatusCode}" });
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or System.Text.Json.JsonException && !ct.IsCancellationRequested)
|
||||
{
|
||||
return (null, new ViewRefusal { Reason = "unreachable" });
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<IPAddress> ResolveV4(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MeWeb.Core.Checks;
|
||||
|
||||
public class SharePayload
|
||||
{
|
||||
public string App { get; set; }
|
||||
public string Platform { get; set; }
|
||||
public string Language { get; set; }
|
||||
public DateTimeOffset CheckedAt { get; set; }
|
||||
public List<Finding> Findings { get; set; } = [];
|
||||
|
||||
public static SharePayload From(RunReport run, string app, string platform, string language) => new()
|
||||
{
|
||||
App = app,
|
||||
Platform = platform,
|
||||
Language = language,
|
||||
CheckedAt = run.StartedAt,
|
||||
Findings = [.. run.Findings.OrderBy(f => f.Id)],
|
||||
};
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, ShareJsonContext.Default.SharePayload);
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, WriteIndented = true, UseStringEnumConverter = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(SharePayload))]
|
||||
public partial class ShareJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using MeWeb.Core.Mail;
|
||||
using MeWeb.Core.Net;
|
||||
using MeWeb.Shared;
|
||||
|
||||
namespace MeWeb.Core.Checks;
|
||||
|
||||
public enum UseId
|
||||
{
|
||||
Website,
|
||||
RemoteAccess,
|
||||
Files,
|
||||
GameServer,
|
||||
Fediverse,
|
||||
Mail,
|
||||
}
|
||||
|
||||
public enum UseVerdict
|
||||
{
|
||||
Possible,
|
||||
Workaround,
|
||||
NotPossible,
|
||||
CannotTell,
|
||||
}
|
||||
|
||||
public enum Inbound
|
||||
{
|
||||
Measured,
|
||||
Likely,
|
||||
Blocked,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
public class UseFinding
|
||||
{
|
||||
public UseId Id { get; set; }
|
||||
public UseVerdict Verdict { get; set; }
|
||||
public string Reason { get; set; }
|
||||
public string Advice { get; set; }
|
||||
public List<string> Args { get; set; } = [];
|
||||
}
|
||||
|
||||
public static class Uses
|
||||
{
|
||||
public static List<UseFinding> Decide(RunReport run)
|
||||
{
|
||||
var v4 = InboundV4(run);
|
||||
var v6 = InboundV6(run);
|
||||
var phone = run.Device == DeviceKind.Phone;
|
||||
var stable = run.History is { Checks: >= 2, Changes: 0 };
|
||||
var name = run.Provider?.Name ?? (run.NetworkV4 ?? run.NetworkV6)?.Name ?? "";
|
||||
var list = new List<UseFinding>
|
||||
{
|
||||
Reachable(UseId.Website, v4, v6, run.Nat, name, webNeedsV4: true),
|
||||
Reachable(UseId.RemoteAccess, v4, v6, run.Nat, name, webNeedsV4: false),
|
||||
Reachable(UseId.Files, v4, v6, run.Nat, name, webNeedsV4: false),
|
||||
Reachable(UseId.GameServer, v4, v6, run.Nat, name, webNeedsV4: true),
|
||||
};
|
||||
var website = list[0];
|
||||
var fediverse = new UseFinding { Id = UseId.Fediverse, Verdict = website.Verdict, Reason = website.Reason, Advice = website.Advice, Args = website.Args };
|
||||
if (website.Verdict == UseVerdict.Possible && !stable)
|
||||
{
|
||||
fediverse.Verdict = UseVerdict.Workaround;
|
||||
fediverse.Reason = "UseReasonAddressMayChange";
|
||||
fediverse.Advice = "UseAdviceDynamicName";
|
||||
}
|
||||
list.Add(fediverse);
|
||||
list.Add(Mail(run, v4));
|
||||
if (phone)
|
||||
foreach (var use in list.Where(u => u.Verdict == UseVerdict.Possible && u.Id is UseId.Website or UseId.Files or UseId.Fediverse or UseId.GameServer or UseId.Mail))
|
||||
{
|
||||
use.Verdict = UseVerdict.Workaround;
|
||||
use.Reason = "UseReasonPhone";
|
||||
use.Advice = "UseAdviceAlwaysOn";
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static Inbound InboundV4(RunReport run)
|
||||
{
|
||||
var probe = run.Reach?.V4;
|
||||
if (probe is not null)
|
||||
{
|
||||
if (probe.Ports.Any(p => p.Outcome == ProbeOutcome.Reached)) return Inbound.Measured;
|
||||
if (run.Reach.Choice == ReachChoice.OpenPorts && run.Reach.Mappings.Any(m => m.Done)) return Inbound.Blocked;
|
||||
}
|
||||
return run.Nat switch
|
||||
{
|
||||
NatKind.Direct or NatKind.HomeRouter => Inbound.Likely,
|
||||
NatKind.Shared or NatKind.Translated or NatKind.NoIpv4 => Inbound.Blocked,
|
||||
NatKind.DoubleNat => Inbound.Blocked,
|
||||
_ => Inbound.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
public static Inbound InboundV6(RunReport run)
|
||||
{
|
||||
var probe = run.Reach?.V6;
|
||||
if (probe is not null) return probe.Ports.Any(p => p.Outcome == ProbeOutcome.Reached) ? Inbound.Measured : Inbound.Blocked;
|
||||
var direct = run.PublicV6 is not null && run.Local?.Active?.Addresses.Any(a => Addresses.SameAddress(a, run.PublicV6)) == true;
|
||||
return direct ? Inbound.Likely : run.PublicV6 is null ? Inbound.Blocked : Inbound.Unknown;
|
||||
}
|
||||
|
||||
static UseFinding Reachable(UseId id, Inbound v4, Inbound v6, NatKind nat, string provider, bool webNeedsV4)
|
||||
{
|
||||
var use = new UseFinding { Id = id };
|
||||
if (v4 == Inbound.Measured)
|
||||
{
|
||||
use.Verdict = UseVerdict.Possible;
|
||||
use.Reason = "UseReasonReachedV4";
|
||||
return use;
|
||||
}
|
||||
if (v4 == Inbound.Likely)
|
||||
{
|
||||
use.Verdict = UseVerdict.Possible;
|
||||
use.Reason = "UseReasonRouterHolds";
|
||||
use.Advice = "UseAdviceForward";
|
||||
return use;
|
||||
}
|
||||
if (v6 is Inbound.Measured or Inbound.Likely && !webNeedsV4)
|
||||
{
|
||||
use.Verdict = UseVerdict.Possible;
|
||||
use.Reason = v6 == Inbound.Measured ? "UseReasonReachedV6" : "UseReasonIpv6Direct";
|
||||
use.Advice = v6 == Inbound.Measured ? null : "UseAdviceIpv6Firewall";
|
||||
return use;
|
||||
}
|
||||
if (v4 == Inbound.Unknown && v6 == Inbound.Unknown)
|
||||
{
|
||||
use.Verdict = UseVerdict.CannotTell;
|
||||
use.Reason = "UseReasonUnknown";
|
||||
return use;
|
||||
}
|
||||
use.Verdict = UseVerdict.Workaround;
|
||||
(use.Reason, use.Advice) = nat switch
|
||||
{
|
||||
NatKind.Shared or NatKind.Translated => ("UseReasonShared", "UseAdviceOwnAddress"),
|
||||
NatKind.DoubleNat => ("UseReasonDouble", "UseAdviceBridge"),
|
||||
NatKind.NoIpv4 => ("UseReasonNoIpv4", "UseAdviceTunnel"),
|
||||
_ => ("UseReasonBlocked", "UseAdviceTunnel"),
|
||||
};
|
||||
if (v6 is Inbound.Measured or Inbound.Likely) use.Reason = "UseReasonOnlyIpv6";
|
||||
use.Args = [provider];
|
||||
return use;
|
||||
}
|
||||
|
||||
static UseFinding Mail(RunReport run, Inbound v4)
|
||||
{
|
||||
var use = new UseFinding { Id = UseId.Mail };
|
||||
var mail = run.Mail;
|
||||
if (mail is null)
|
||||
{
|
||||
use.Verdict = UseVerdict.CannotTell;
|
||||
use.Reason = "UseReasonUnknown";
|
||||
return use;
|
||||
}
|
||||
if (mail.Port25 == PortState.Blocked)
|
||||
{
|
||||
use.Verdict = UseVerdict.Workaround;
|
||||
use.Reason = "UseReasonPort25Blocked";
|
||||
use.Advice = "UseAdviceRelay";
|
||||
return use;
|
||||
}
|
||||
if (mail.Listings.Any(l => l.Residential) || mail.ReverseLooksDynamic || mail.ReverseName is null)
|
||||
{
|
||||
use.Verdict = UseVerdict.Workaround;
|
||||
use.Reason = mail.Listings.Any(l => l.Residential) ? "UseReasonResidentialList" : mail.ReverseName is null ? "UseReasonNoReverse" : "UseReasonDynamicReverse";
|
||||
use.Advice = "UseAdviceRelay";
|
||||
return use;
|
||||
}
|
||||
if (v4 is Inbound.Blocked or Inbound.Unknown)
|
||||
{
|
||||
use.Verdict = UseVerdict.Workaround;
|
||||
use.Reason = "UseReasonBlocked";
|
||||
use.Advice = "UseAdviceRelayBoth";
|
||||
return use;
|
||||
}
|
||||
use.Verdict = mail.Port25 == PortState.Open ? UseVerdict.Possible : UseVerdict.CannotTell;
|
||||
use.Reason = mail.Port25 == PortState.Open ? "UseReasonMailClean" : "UseReasonUnknown";
|
||||
return use;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using MeWeb.Core.History;
|
||||
using MeWeb.Core.Knowledge;
|
||||
using MeWeb.Core.Net;
|
||||
using MeWeb.Core.Router;
|
||||
using MeWeb.Shared;
|
||||
|
||||
namespace MeWeb.Core.Checks;
|
||||
|
||||
@@ -201,6 +202,186 @@ public static class Verdicts
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
Reference in new issue
Block a user