me-web? 0.2: the test from outside, calls and games, mail, and what you can host
Build / Desktop, server and tools (push) Successful in 59s
Release / Every platform, the server, the release (push) Successful in 7m27s

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:
thepraandClaude Opus 5.5 committed 2026-10-15 05:41:44 +02:00
1 parent f79682eaef
commit 7cfc8b6de5
161 files changed
+23159 -305

No files matched your search

+51 -3
View File
@@ -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;
}
+6
View File
@@ -8,6 +8,12 @@ public enum CheckId
Provider,
Router,
Nat,
Behaviour,
Reach,
Mail,
Dns,
Speed,
Device,
History,
}
+126
View File
@@ -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;
}
}
}
+58
View File
@@ -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
+30
View File
@@ -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
{
}
+180
View File
@@ -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;
}
}
+181
View File
@@ -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 };
+64
View File
@@ -0,0 +1,64 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MeWeb.Core.History;
public class Submission
{
public string Id { get; set; }
public string Token { get; set; }
public string KeepUntil { get; set; }
public DateTimeOffset SentAt { get; set; }
}
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, WriteIndented = true)]
[JsonSerializable(typeof(List<Submission>))]
public partial class SubmissionJsonContext : JsonSerializerContext
{
}
public class Submissions(string folder)
{
readonly Lock gate = new();
string FilePath => Path.Combine(folder, "submissions.json");
public List<Submission> Read()
{
lock (gate)
{
try
{
if (!File.Exists(FilePath)) return [];
using var stream = File.OpenRead(FilePath);
return JsonSerializer.Deserialize(stream, SubmissionJsonContext.Default.ListSubmission) ?? [];
}
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
{
return [];
}
}
}
public void Add(Submission submission) => Save([.. Read(), submission]);
public void Remove(string id) => Save(Read().Where(s => s.Id != id).ToList());
void Save(List<Submission> list)
{
lock (gate)
{
try
{
Directory.CreateDirectory(folder);
var temporary = FilePath + ".tmp";
using (var stream = File.Create(temporary))
JsonSerializer.Serialize(stream, list, SubmissionJsonContext.Default.ListSubmission);
File.Move(temporary, FilePath, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
}
}
}
}
+178
View File
@@ -0,0 +1,178 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.RegularExpressions;
using MeWeb.Core.Net;
namespace MeWeb.Core.Mail;
public enum PortState
{
Open,
Blocked,
Unknown,
}
public class MailReport
{
public PortState Port25 { get; set; } = PortState.Unknown;
public List<string> Tried { get; set; } = [];
public string Banner { get; set; }
public string ReverseName { get; set; }
public bool ReverseLooksDynamic { get; set; }
public bool ReverseConfirmed { get; set; }
public List<Listing> Listings { get; set; } = [];
}
public enum ListingState
{
Clean,
Listed,
CannotTell,
}
public class Listing
{
public string List { get; set; }
public string Zone { get; set; }
public ListingState State { get; set; }
public string Code { get; set; }
public bool Residential { get; set; }
public bool Wide { get; set; }
}
public static partial class MailChecks
{
public static readonly (string Host, string Label)[] MailHosts =
[
("gmail-smtp-in.l.google.com", "Gmail"),
("outlook-com.olc.protection.outlook.com", "Outlook"),
];
static readonly (string Name, string Zone)[] Lists =
[
("Spamhaus ZEN", "zen.spamhaus.org"),
("UCEPROTECT 1", "dnsbl-1.uceprotect.net"),
("UCEPROTECT 2", "dnsbl-2.uceprotect.net"),
("UCEPROTECT 3", "dnsbl-3.uceprotect.net"),
("SpamCop", "bl.spamcop.net"),
("Barracuda", "b.barracudacentral.org"),
];
public static async Task<MailReport> Run(string publicV4, CancellationToken ct)
{
var report = new MailReport();
var address = Addresses.Parse(publicV4);
var port = Port25(report, ct);
var reverse = address is null ? Task.CompletedTask : Reverse(report, address, ct);
var lists = address is null ? Task.CompletedTask : Blocklists(report, address, ct);
await Task.WhenAll(port, reverse, lists);
return report;
}
static async Task Port25(MailReport report, CancellationToken ct)
{
var anyAnswer = false;
foreach (var (host, label) in MailHosts)
{
report.Tried.Add(label);
try
{
var addresses = await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork, ct);
if (addresses.Length == 0) continue;
using var client = new TcpClient(AddressFamily.InterNetwork);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(6));
await client.ConnectAsync(addresses[0], 25, timeout.Token);
var stream = client.GetStream();
var buffer = new byte[512];
var read = await stream.ReadAsync(buffer, timeout.Token);
var banner = Encoding.ASCII.GetString(buffer, 0, read).Trim();
await stream.WriteAsync("QUIT\r\n"u8.ToArray(), timeout.Token);
if (banner.StartsWith("220", StringComparison.Ordinal))
{
report.Port25 = PortState.Open;
report.Banner = label;
return;
}
anyAnswer = true;
}
catch (Exception ex) when (ex is SocketException or IOException or OperationCanceledException && !ct.IsCancellationRequested)
{
}
}
report.Port25 = anyAnswer ? PortState.Unknown : PortState.Blocked;
}
static async Task Reverse(MailReport report, IPAddress address, CancellationToken ct)
{
try
{
var entry = await Dns.GetHostEntryAsync(address.ToString(), ct);
if (string.IsNullOrEmpty(entry.HostName) || entry.HostName == address.ToString()) return;
report.ReverseName = entry.HostName.TrimEnd('.');
report.ReverseLooksDynamic = LooksDynamic(report.ReverseName, address);
var forward = await Dns.GetHostAddressesAsync(report.ReverseName, AddressFamily.InterNetwork, ct);
report.ReverseConfirmed = forward.Any(a => a.Equals(address));
}
catch (SocketException)
{
}
}
public static bool LooksDynamic(string name, IPAddress address)
{
var lower = name.ToLowerInvariant();
if (ServerWords().IsMatch(lower)) return false;
var octets = address.GetAddressBytes().Select(b => b.ToString()).ToArray();
var reversed = octets.Reverse().ToArray();
foreach (var separator in new[] { "-", ".", "_", "" })
if (lower.Contains(string.Join(separator, octets)) || lower.Contains(string.Join(separator, reversed))) return true;
return HomeWords().IsMatch(lower);
}
static async Task Blocklists(MailReport report, IPAddress address, CancellationToken ct)
{
var reversed = string.Join('.', address.GetAddressBytes().Reverse());
var checks = Lists.Select(async list =>
{
var listing = new Listing { List = list.Name, Zone = list.Zone, Wide = list.Zone is "dnsbl-2.uceprotect.net" or "dnsbl-3.uceprotect.net" };
try
{
var answers = await Dns.GetHostAddressesAsync($"{reversed}.{list.Zone}", AddressFamily.InterNetwork, ct);
var first = answers.FirstOrDefault();
if (first is null)
{
listing.State = ListingState.Clean;
return listing;
}
var bytes = first.GetAddressBytes();
listing.Code = first.ToString();
if (bytes is [127, 255, 255, _])
{
listing.State = ListingState.CannotTell;
return listing;
}
listing.State = bytes is [127, ..] ? ListingState.Listed : ListingState.CannotTell;
listing.Residential = list.Zone == "zen.spamhaus.org" && bytes is [127, 0, 0, 10 or 11];
}
catch (SocketException ex) when (ex.SocketErrorCode is SocketError.HostNotFound or SocketError.NoData)
{
listing.State = ListingState.Clean;
}
catch (SocketException)
{
listing.State = ListingState.CannotTell;
}
return listing;
});
report.Listings = [.. await Task.WhenAll(checks)];
}
[GeneratedRegex(@"\b(static|mail|mx\d*|smtp|server|srv|vps|ded|colo)\b")]
internal static partial Regex ServerWords();
[GeneratedRegex(@"(dyn|dynamic|pool|dsl|adsl|vdsl|cable|dhcp|ppp|pppoe|client|cust|customer|resid|home|user|broadband|ftth|fttc|fttx|cpe|wifi|mobile|nat)")]
internal static partial Regex HomeWords();
}
+103
View File
@@ -0,0 +1,103 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Text;
using MeWeb.Shared;
namespace MeWeb.Core.Net;
public sealed class Answerer : IAsyncDisposable
{
readonly TcpListener listener;
readonly ConcurrentDictionary<string, byte[]> keys = new();
readonly CancellationTokenSource stop = new();
readonly Task loop;
Answerer(TcpListener listener)
{
this.listener = listener;
loop = Task.Run(Accept);
}
public void Accept(string nonce, byte[] key) => keys[nonce] = key;
public int Port => ((IPEndPoint)listener.LocalEndpoint).Port;
public ConcurrentQueue<string> Callers { get; } = new();
public static Answerer Start()
{
for (var attempt = 0; attempt < 12; attempt++)
{
var dual = Socket.OSSupportsIPv6;
var listener = new TcpListener(dual ? IPAddress.IPv6Any : IPAddress.Any, Random.Shared.Next(49152, 65536));
try
{
if (dual) listener.Server.DualMode = true;
listener.Start();
return new Answerer(listener);
}
catch (SocketException)
{
listener.Stop();
}
}
throw new InvalidOperationException("no free port for the answerer");
}
async Task Accept()
{
while (!stop.IsCancellationRequested)
{
TcpClient client;
try
{
client = await listener.AcceptTcpClientAsync(stop.Token);
}
catch (Exception ex) when (ex is OperationCanceledException or SocketException or ObjectDisposedException)
{
return;
}
_ = Task.Run(() => Answer(client));
}
}
async Task Answer(TcpClient client)
{
using (client)
{
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stop.Token);
timeout.CancelAfter(TimeSpan.FromSeconds(4));
var stream = client.GetStream();
var buffer = new byte[128];
var length = 0;
while (length < buffer.Length && Array.IndexOf(buffer, (byte)'\n', 0, length) < 0)
{
var read = await stream.ReadAsync(buffer.AsMemory(length), timeout.Token);
if (read == 0) return;
length += read;
}
var parts = Encoding.ASCII.GetString(buffer, 0, length).Trim().Split(' ');
if (parts.Length != 3 || parts[0] != ProbeProof.Greeting || !keys.TryGetValue(parts[1], out var key) || !int.TryParse(parts[2], out var port)) return;
await stream.WriteAsync(Encoding.ASCII.GetBytes(ProbeProof.For(key, parts[1], port) + "\n"), timeout.Token);
var remote = (IPEndPoint)client.Client.RemoteEndPoint;
var address = remote.Address.IsIPv4MappedToIPv6 ? remote.Address.MapToIPv4() : remote.Address;
Callers.Enqueue($"{address}|{port}");
}
catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
{
}
}
}
public async ValueTask DisposeAsync()
{
stop.Cancel();
listener.Stop();
await loop;
stop.Dispose();
}
}
+37
View File
@@ -0,0 +1,37 @@
using System.Runtime.InteropServices;
namespace MeWeb.Core.Net;
public class DeviceReport
{
public DeviceKind Kind { get; set; }
public string System { get; set; }
public int Cores { get; set; }
public long MemoryMb { get; set; }
public long FreeDiskGb { get; set; }
public bool? OnBattery { get; set; }
}
public static class DeviceCheck
{
public static DeviceReport Read(IPlatform platform, string folder)
{
var report = new DeviceReport
{
Kind = platform.Device,
System = $"{RuntimeInformation.OSDescription} ({RuntimeInformation.OSArchitecture})".Trim(),
Cores = Environment.ProcessorCount,
MemoryMb = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024),
OnBattery = platform.HasBattery,
};
try
{
var root = Path.GetPathRoot(Path.GetFullPath(folder)) ?? "/";
report.FreeDiskGb = new DriveInfo(root).AvailableFreeSpace / (1024L * 1024 * 1024);
}
catch (Exception ex) when (ex is IOException or ArgumentException or UnauthorizedAccessException)
{
}
return report;
}
}
+57
View File
@@ -0,0 +1,57 @@
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using MeWeb.Shared;
namespace MeWeb.Core.Net;
public class DnsReport
{
public List<string> Resolvers { get; set; } = [];
public bool? Validates { get; set; }
public bool? Hijacks { get; set; }
public bool? Ipv6Names { get; set; }
}
public static class DnsChecks
{
public const string BrokenSignature = "dnssec-failed.org";
public static async Task<DnsReport> Run(LocalSnapshot local, CancellationToken ct)
{
var report = new DnsReport { Resolvers = local.Dns };
var known = Resolves(Endpoints.Host, AddressFamily.InterNetwork, ct);
var broken = Resolves(BrokenSignature, AddressFamily.InterNetwork, ct);
var missing = Resolves($"meweb-{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(6))}.{Endpoints.Host}", AddressFamily.InterNetwork, ct);
var v6 = Resolves(Endpoints.HostV6, AddressFamily.InterNetworkV6, ct);
await Task.WhenAll(known, broken, missing, v6);
if (known.Result != true) return report;
report.Validates = broken.Result is null ? null : !broken.Result;
report.Hijacks = missing.Result;
report.Ipv6Names = v6.Result;
return report;
}
static async Task<bool?> Resolves(string name, AddressFamily family, CancellationToken ct)
{
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
return (await Dns.GetHostAddressesAsync(name, family, timeout.Token)).Length > 0;
}
catch (SocketException ex) when (ex.SocketErrorCode is SocketError.HostNotFound or SocketError.NoData)
{
return false;
}
catch (SocketException)
{
return false;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return null;
}
}
}
+27
View File
@@ -51,6 +51,12 @@ public interface IPlatform
{
DeviceKind Device { get; }
string Name { get; }
bool ShareLink(string url, string title);
bool? HasBattery { get; }
LocalSnapshot ReadNetwork();
IDisposable AllowMulticast();
@@ -60,6 +66,27 @@ public class SystemPlatform : IPlatform
{
public virtual DeviceKind Device => DeviceKind.Computer;
public virtual string Name => OperatingSystem.IsWindows() ? "windows" : OperatingSystem.IsMacOS() ? "macos" : OperatingSystem.IsLinux() ? "linux" : "other";
public virtual bool ShareLink(string url, string title) => false;
public virtual bool? HasBattery
{
get
{
if (!OperatingSystem.IsLinux()) return null;
try
{
return Directory.Exists("/sys/class/power_supply") && Directory.EnumerateDirectories("/sys/class/power_supply")
.Any(d => File.Exists(Path.Combine(d, "type")) && File.ReadAllText(Path.Combine(d, "type")).Trim() == "Battery");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
}
public virtual IDisposable AllowMulticast() => new Nothing();
public virtual LocalSnapshot ReadNetwork()
+58
View File
@@ -0,0 +1,58 @@
using System.Diagnostics;
using MeWeb.Shared;
namespace MeWeb.Core.Net;
public class SpeedReport
{
public double DownMbps { get; set; }
public double UpMbps { get; set; }
public int DownBytes { get; set; }
public int UpBytes { get; set; }
public bool Limited { get; set; }
}
public static class SpeedCheck
{
public static async Task<SpeedReport> Run(HttpClient https, LinkKind kind, CancellationToken ct)
{
var mobile = kind == LinkKind.Mobile;
var report = new SpeedReport { DownBytes = mobile ? 2 * 1024 * 1024 : 12 * 1024 * 1024, UpBytes = mobile ? 1024 * 1024 : 4 * 1024 * 1024 };
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
var clock = Stopwatch.StartNew();
using (var response = await https.GetAsync(Endpoints.Base + Endpoints.Speed.GET_M_Down(report.DownBytes), HttpCompletionOption.ResponseHeadersRead, timeout.Token))
{
if ((int)response.StatusCode == 429)
{
report.Limited = true;
return report;
}
response.EnsureSuccessStatusCode();
await using var body = await response.Content.ReadAsStreamAsync(timeout.Token);
var buffer = new byte[64 * 1024];
long total = 0;
int read;
while ((read = await body.ReadAsync(buffer, timeout.Token)) > 0) total += read;
report.DownMbps = Mbps(total, clock.Elapsed);
}
var payload = new byte[report.UpBytes];
Random.Shared.NextBytes(payload);
clock.Restart();
using (var response = await https.PostAsync(Endpoints.Base + Endpoints.Speed.POST_Up, new ByteArrayContent(payload), timeout.Token))
{
if (response.IsSuccessStatusCode) report.UpMbps = Mbps(payload.Length, clock.Elapsed);
}
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException && !ct.IsCancellationRequested)
{
}
return report;
}
static double Mbps(long bytes, TimeSpan elapsed) => elapsed.TotalSeconds <= 0 ? 0 : Math.Round(bytes * 8 / elapsed.TotalSeconds / 1_000_000, 1);
}
+112
View File
@@ -0,0 +1,112 @@
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
namespace MeWeb.Core.Net;
public enum NatBehaviour
{
Unknown,
None,
Easy,
Hard,
}
public class StunReport
{
public int LocalPort { get; set; }
public string FirstServer { get; set; }
public string FirstMapped { get; set; }
public string SecondServer { get; set; }
public string SecondMapped { get; set; }
public NatBehaviour Behaviour { get; set; }
}
public static class Stun
{
const uint Cookie = 0x2112A442;
public static async Task<StunReport> Compare((string Host, int Port) first, (string Host, int Port) second, LocalSnapshot local, CancellationToken ct)
{
using var udp = new UdpClient(new IPEndPoint(IPAddress.Any, 0));
var report = new StunReport
{
LocalPort = ((IPEndPoint)udp.Client.LocalEndPoint).Port,
FirstServer = $"{first.Host}:{first.Port}",
SecondServer = $"{second.Host}:{second.Port}",
};
var a = await Binding(udp, first, ct);
var b = await Binding(udp, second, ct);
report.FirstMapped = a?.ToString();
report.SecondMapped = b?.ToString();
if (a is null || b is null) return report;
var own = local.Active?.Addresses.Any(x => Addresses.SameAddress(x, a.Address.ToString())) == true;
report.Behaviour = own && a.Port == report.LocalPort ? NatBehaviour.None : a.Equals(b) ? NatBehaviour.Easy : NatBehaviour.Hard;
return report;
}
static async Task<IPEndPoint> Binding(UdpClient udp, (string Host, int Port) server, CancellationToken ct)
{
try
{
var addresses = await Dns.GetHostAddressesAsync(server.Host, AddressFamily.InterNetwork, ct);
if (addresses.Length == 0) return null;
var target = new IPEndPoint(addresses[0], server.Port);
var request = new byte[20];
BinaryPrimitives.WriteUInt16BigEndian(request, 0x0001);
BinaryPrimitives.WriteUInt32BigEndian(request.AsSpan(4), Cookie);
RandomNumberGenerator.Fill(request.AsSpan(8, 12));
foreach (var wait in new[] { 800, 1600 })
{
await udp.SendAsync(request, target, ct);
using var window = CancellationTokenSource.CreateLinkedTokenSource(ct);
window.CancelAfter(wait);
try
{
while (true)
{
var received = await udp.ReceiveAsync(window.Token);
if (!received.RemoteEndPoint.Equals(target) || received.Buffer.Length < 20 || !received.Buffer.AsSpan(8, 12).SequenceEqual(request.AsSpan(8, 12))) continue;
return Mapped(received.Buffer);
}
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
}
}
return null;
}
catch (SocketException)
{
return null;
}
}
static IPEndPoint Mapped(byte[] message)
{
IPEndPoint plain = null;
var at = 20;
while (at + 4 <= message.Length)
{
var type = BinaryPrimitives.ReadUInt16BigEndian(message.AsSpan(at));
var length = BinaryPrimitives.ReadUInt16BigEndian(message.AsSpan(at + 2));
if (at + 4 + length > message.Length) break;
var value = message.AsSpan(at + 4, length);
if (length >= 8 && value[1] == 1)
{
if (type == 0x0020)
{
var port = BinaryPrimitives.ReadUInt16BigEndian(value[2..]) ^ (int)(Cookie >> 16);
var address = BinaryPrimitives.ReadUInt32BigEndian(value[4..]) ^ Cookie;
var bytes = new byte[4];
BinaryPrimitives.WriteUInt32BigEndian(bytes, address);
return new IPEndPoint(new IPAddress(bytes), port);
}
if (type == 0x0001) plain = new IPEndPoint(new IPAddress(value.Slice(4, 4)), BinaryPrimitives.ReadUInt16BigEndian(value[2..]));
}
at += 4 + length + (4 - length % 4) % 4;
}
return plain;
}
}
+20 -8
View File
@@ -53,6 +53,8 @@ public static class Gateway
var baseText = description.Root.Elements().FirstOrDefault(e => e.Name.LocalName == "URLBase")?.Value;
var baseUri = Uri.TryCreate(baseText, UriKind.Absolute, out var parsed) && IsLocal(parsed) ? parsed : location;
if (!Uri.TryCreate(baseUri, service.Control, out var control) || !IsLocal(control)) return;
report.UpnpControl = control.ToString();
report.UpnpService = service.Type;
await ExternalAddress(control, service.Type, report, ct);
}
catch (Exception ex) when (ex is HttpRequestException or XmlException or TaskCanceledException && !ct.IsCancellationRequested)
@@ -60,22 +62,32 @@ public static class Gateway
}
}
static async Task ExternalAddress(Uri control, string serviceType, RouterReport report, CancellationToken ct)
public static async Task<(XDocument Answer, int Status)> Soap(Uri control, string serviceType, string action, string arguments, CancellationToken ct)
{
var body = $"""<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:GetExternalIPAddress xmlns:u="{serviceType}"/></s:Body></s:Envelope>""";
var body = $"""<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:{action} xmlns:u="{serviceType}">{arguments}</u:{action}></s:Body></s:Envelope>""";
using var request = new HttpRequestMessage(HttpMethod.Post, control) { Content = new StringContent(body, Encoding.UTF8) };
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("text/xml; charset=\"utf-8\"");
request.Headers.TryAddWithoutValidation("SOAPAction", $"\"{serviceType}#GetExternalIPAddress\"");
request.Headers.TryAddWithoutValidation("SOAPAction", $"\"{serviceType}#{action}\"");
using var response = await Lan.SendAsync(request, ct);
var answer = XDocument.Parse(await response.Content.ReadAsStringAsync(ct));
return (XDocument.Parse(await response.Content.ReadAsStringAsync(ct)), (int)response.StatusCode);
}
public static int ErrorCode(XDocument answer, int status)
{
var code = answer.Descendants().FirstOrDefault(e => e.Name.LocalName == "errorCode")?.Value;
return int.TryParse(code, out var number) ? number : status;
}
static async Task ExternalAddress(Uri control, string serviceType, RouterReport report, CancellationToken ct)
{
var (answer, status) = await Soap(control, serviceType, "GetExternalIPAddress", "", ct);
var external = answer.Descendants().FirstOrDefault(e => e.Name.LocalName == "NewExternalIPAddress")?.Value?.Trim();
if (Addresses.Parse(external) is not null)
{
report.WanAddress = external;
return;
}
var code = answer.Descendants().FirstOrDefault(e => e.Name.LocalName == "errorCode")?.Value;
report.UpnpError = int.TryParse(code, out var number) ? number : (int)response.StatusCode;
report.UpnpError = ErrorCode(answer, status);
report.Upnp = Answer.Refused;
}
@@ -156,7 +168,7 @@ public static class Gateway
report.Pcp = answer[3] == 0 ? Answer.Answered : Answer.Refused;
}
static async Task<byte[]> Exchange(IPAddress gateway, byte[] request, CancellationToken ct)
public static async Task<byte[]> Exchange(IPAddress gateway, byte[] request, CancellationToken ct)
{
using var udp = new UdpClient(AddressFamily.InterNetwork);
try
@@ -170,7 +182,7 @@ public static class Gateway
return await Exchange(udp, request, ct);
}
static async Task<byte[]> Exchange(UdpClient udp, byte[] request, CancellationToken ct)
public static async Task<byte[]> Exchange(UdpClient udp, byte[] request, CancellationToken ct)
{
foreach (var wait in new[] { 1000, 2000 })
{
+81
View File
@@ -0,0 +1,81 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MeWeb.Core.Router;
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, UseStringEnumConverter = true)]
[JsonSerializable(typeof(List<Mapping>))]
public partial class MappingJsonContext : JsonSerializerContext
{
}
public class Leftovers(string folder)
{
readonly Lock gate = new();
string FilePath => Path.Combine(folder, "mappings.json");
public void Remember(Mapping mapping)
{
lock (gate)
{
var list = Read();
list.Add(mapping);
Write(list);
}
}
public void Forget(Mapping mapping)
{
lock (gate)
{
var list = Read();
list.RemoveAll(m => m.Via == mapping.Via && m.Gateway == mapping.Gateway && m.ExternalPort == mapping.ExternalPort);
Write(list);
}
}
public async Task<int> Clean(CancellationToken ct)
{
List<Mapping> list;
lock (gate) list = Read();
foreach (var mapping in list.Where(m => m.Until > DateTimeOffset.UtcNow.AddMinutes(-1)))
await PortMapper.Remove(mapping, ct);
lock (gate) Write([]);
return list.Count;
}
List<Mapping> Read()
{
try
{
if (!File.Exists(FilePath)) return [];
using var stream = File.OpenRead(FilePath);
return JsonSerializer.Deserialize(stream, MappingJsonContext.Default.ListMapping) ?? [];
}
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
{
return [];
}
}
void Write(List<Mapping> list)
{
try
{
Directory.CreateDirectory(folder);
if (list.Count == 0)
{
File.Delete(FilePath);
return;
}
var temporary = FilePath + ".tmp";
using (var stream = File.Create(temporary))
JsonSerializer.Serialize(stream, list, MappingJsonContext.Default.ListMapping);
File.Move(temporary, FilePath, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
}
}
}
+189
View File
@@ -0,0 +1,189 @@
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Xml;
using MeWeb.Core.Net;
namespace MeWeb.Core.Router;
public enum MapVia
{
None,
Upnp,
NatPmp,
Pcp,
}
public class Mapping
{
public MapVia Via { get; set; }
public string Gateway { get; set; }
public int ExternalPort { get; set; }
public int InternalPort { get; set; }
public string InternalClient { get; set; }
public string Control { get; set; }
public string Service { get; set; }
public string Nonce { get; set; }
public DateTimeOffset Until { get; set; }
}
public class MappingAttempt
{
public int ExternalPort { get; set; }
public Mapping Mapping { get; set; }
public MapVia Via { get; set; }
public int Error { get; set; }
public bool Done => Mapping is not null;
}
public static class PortMapper
{
public const int LeaseSeconds = 600;
const int PortMappingPort = 5351;
public static async Task<MappingAttempt> Map(RouterReport router, string localAddress, int external, int @internal, CancellationToken ct)
{
var attempt = new MappingAttempt { ExternalPort = external };
if (router.UpnpControl is not null)
{
attempt.Via = MapVia.Upnp;
(attempt.Mapping, attempt.Error) = await Upnp(router, localAddress, external, @internal, ct);
if (attempt.Done) return attempt;
}
if (router.NatPmp == Answer.Answered)
{
attempt.Via = MapVia.NatPmp;
(attempt.Mapping, attempt.Error) = await NatPmp(router.Gateway, external, @internal, ct);
if (attempt.Done) return attempt;
}
if (router.Pcp != Answer.Silent)
{
attempt.Via = MapVia.Pcp;
(attempt.Mapping, attempt.Error) = await Pcp(router.Gateway, localAddress, external, @internal, ct);
}
return attempt;
}
public static async Task Remove(Mapping mapping, CancellationToken ct)
{
try
{
switch (mapping.Via)
{
case MapVia.Upnp:
await Gateway.Soap(new Uri(mapping.Control), mapping.Service, "DeletePortMapping",
$"<NewRemoteHost></NewRemoteHost><NewExternalPort>{mapping.ExternalPort}</NewExternalPort><NewProtocol>TCP</NewProtocol>", ct);
break;
case MapVia.NatPmp:
await Gateway.Exchange(IPAddress.Parse(mapping.Gateway), NatPmpRequest(mapping.InternalPort, 0, 0), ct);
break;
case MapVia.Pcp:
await PcpExchange(mapping.Gateway, mapping.InternalClient, Convert.FromBase64String(mapping.Nonce), mapping.ExternalPort, mapping.InternalPort, 0, ct);
break;
}
}
catch (Exception ex) when (ex is HttpRequestException or XmlException or SocketException or FormatException or TaskCanceledException && !ct.IsCancellationRequested)
{
}
}
static async Task<(Mapping, int)> Upnp(RouterReport router, string localAddress, int external, int @internal, CancellationToken ct)
{
var control = new Uri(router.UpnpControl);
foreach (var lease in new[] { LeaseSeconds, 0 })
{
try
{
var (answer, status) = await Gateway.Soap(control, router.UpnpService, "AddPortMapping",
$"<NewRemoteHost></NewRemoteHost><NewExternalPort>{external}</NewExternalPort><NewProtocol>TCP</NewProtocol>" +
$"<NewInternalPort>{@internal}</NewInternalPort><NewInternalClient>{localAddress}</NewInternalClient><NewEnabled>1</NewEnabled>" +
$"<NewPortMappingDescription>me-web? test</NewPortMappingDescription><NewLeaseDuration>{lease}</NewLeaseDuration>", ct);
var error = status == 200 ? 0 : Gateway.ErrorCode(answer, status);
if (error == 0)
return (new Mapping
{
Via = MapVia.Upnp,
Gateway = router.Gateway,
ExternalPort = external,
InternalPort = @internal,
InternalClient = localAddress,
Control = router.UpnpControl,
Service = router.UpnpService,
Until = lease == 0 ? DateTimeOffset.MaxValue : DateTimeOffset.UtcNow.AddSeconds(lease),
}, 0);
if (error != 725) return (null, error);
}
catch (Exception ex) when (ex is HttpRequestException or XmlException or TaskCanceledException && !ct.IsCancellationRequested)
{
return (null, -1);
}
}
return (null, 725);
}
static async Task<(Mapping, int)> NatPmp(string gateway, int external, int @internal, CancellationToken ct)
{
var answer = await Gateway.Exchange(IPAddress.Parse(gateway), NatPmpRequest(@internal, external, LeaseSeconds), ct);
if (answer is null || answer.Length < 16 || answer[0] != 0 || answer[1] != 130) return (null, -1);
var result = BinaryPrimitives.ReadUInt16BigEndian(answer.AsSpan(2));
if (result != 0) return (null, result);
var mapped = BinaryPrimitives.ReadUInt16BigEndian(answer.AsSpan(10));
var lifetime = BinaryPrimitives.ReadUInt32BigEndian(answer.AsSpan(12));
return (new Mapping
{
Via = MapVia.NatPmp,
Gateway = gateway,
ExternalPort = mapped,
InternalPort = @internal,
Until = DateTimeOffset.UtcNow.AddSeconds(lifetime),
}, 0);
}
static async Task<(Mapping, int)> Pcp(string gateway, string localAddress, int external, int @internal, CancellationToken ct)
{
var nonce = RandomNumberGenerator.GetBytes(12);
var answer = await PcpExchange(gateway, localAddress, nonce, external, @internal, LeaseSeconds, ct);
if (answer is null || answer.Length < 60 || answer[0] != 2 || answer[1] != 0x81) return (null, -1);
if (answer[3] != 0) return (null, answer[3]);
var lifetime = BinaryPrimitives.ReadUInt32BigEndian(answer.AsSpan(4));
var mapped = BinaryPrimitives.ReadUInt16BigEndian(answer.AsSpan(42));
return (new Mapping
{
Via = MapVia.Pcp,
Gateway = gateway,
ExternalPort = mapped,
InternalPort = @internal,
InternalClient = localAddress,
Nonce = Convert.ToBase64String(nonce),
Until = DateTimeOffset.UtcNow.AddSeconds(lifetime),
}, 0);
}
static byte[] NatPmpRequest(int @internal, int external, uint lifetime)
{
var request = new byte[12];
request[1] = 2;
BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(4), (ushort)@internal);
BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(6), (ushort)external);
BinaryPrimitives.WriteUInt32BigEndian(request.AsSpan(8), lifetime);
return request;
}
static async Task<byte[]> PcpExchange(string gateway, string localAddress, byte[] nonce, int external, int @internal, uint lifetime, CancellationToken ct)
{
var request = new byte[60];
request[0] = 2;
request[1] = 1;
BinaryPrimitives.WriteUInt32BigEndian(request.AsSpan(4), lifetime);
(Addresses.Parse(localAddress) ?? IPAddress.Any).MapToIPv6().GetAddressBytes().CopyTo(request, 8);
nonce.CopyTo(request, 24);
request[36] = 6;
BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(40), (ushort)@internal);
BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(42), (ushort)external);
IPAddress.Any.MapToIPv6().GetAddressBytes().CopyTo(request, 44);
return await Gateway.Exchange(IPAddress.Parse(gateway), request, ct);
}
}
+2
View File
@@ -21,6 +21,8 @@ public class RouterReport
public Answer Pcp { get; set; }
public int PcpResult { get; set; } = -1;
public string WanAddress { get; set; }
public string UpnpControl { get; set; }
public string UpnpService { get; set; }
public bool AnyAnswer => Upnp != Answer.Silent || NatPmp != Answer.Silent || Pcp != Answer.Silent;