House rules: the build checks them, and every refusal from the server answers 400, 403 or 404
Build / Desktop, server and tools (push) Successful in 1m8s

The server no longer answers 409, 413 or 429: a busy probe, a share or upload that is too large and a request over
the limit now answer 400 with the same reason as before, and still say when to try again. The app reads the reason,
so nothing changes for it; versions up to 0.15.4 report a limited speed test as unmeasured until they update.
The app's commands can now be cancelled all the way down, and the house rules are written into PATTERNS.md and
checked on every build, Android included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4YtEJrgxG12Pc39Fai8Bd
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-11-10 05:32:04 +01:00
1 parent 5b2210a8a7
commit d408edea98
28 files changed
+235 -145

No files matched your search

+17 -17
View File
@@ -49,14 +49,14 @@ public class Reach(ServerProbe server, Leftovers leftovers)
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);
var ticket4 = run.PublicV4 is null ? new WebResult<ViewProbeTicket>() : await server.Ticket(false, ct);
var ticket6 = run.PublicV6 is null ? new WebResult<ViewProbeTicket>() : 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;
if (ticket4.Data is null && ticket6.Data is null) return report;
Answerer started;
var started = default(Answerer);
try
{
started = Answerer.Start();
@@ -71,13 +71,13 @@ public class Reach(ServerProbe server, Leftovers leftovers)
}
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));
if (ticket4.Data is not null) answerer.Accept(ticket4.Data.Nonce, Convert.FromBase64String(ticket4.Data.Key));
if (ticket6.Data is not null) answerer.Accept(ticket6.Data.Nonce, Convert.FromBase64String(ticket6.Data.Key));
try
{
var local = run.Local?.LocalV4;
if (choice == ReachChoice.OpenPorts && ticket4.Ticket is not null && run.Router?.AnyAnswer == true && local is not null)
if (choice == ReachChoice.OpenPorts && ticket4.Data is not null && run.Router?.AnyAnswer == true && local is not null)
{
foreach (var external in Wellknown.Append(answerer.Port))
{
@@ -92,18 +92,18 @@ public class Reach(ServerProbe server, Leftovers leftovers)
: [.. 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 knock4 = ticket4.Data is null ? Task.FromResult(new WebResult<ViewProbe>()) : server.Knock(false, ticket4.Data.Id, ports4, ct);
var knock6 = ticket6.Data is null ? Task.FromResult(new WebResult<ViewProbe>()) : server.Knock(true, ticket6.Data.Id, [answerer.Port], ct);
var probe4 = await knock4;
var probe6 = await knock6;
report.V4 = probe4.Data;
report.V6 = probe6.Data;
report.V4Refusal ??= probe4.Refusal?.Reason;
report.V6Refusal ??= probe6.Refusal?.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);
if (own is not null && ticket4.Data is not null && Addresses.Parse(run.PublicV4) is { } publicV4)
report.Hairpin = await Knock(publicV4, own.ExternalPort, ticket4.Data, ct);
}
finally
{
+16 -11
View File
@@ -55,23 +55,23 @@ public class ServerProbe(HttpClient https, HttpClient plain)
}
}
public async Task<(ViewProbeTicket Ticket, ViewRefusal Refusal)> Ticket(bool v6, CancellationToken ct)
public async Task<WebResult<ViewProbeTicket>> 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);
return await Send(HttpMethod.Post, origin + Endpoints.Probe.POST_Ticket, null, MeWebJsonContext.Default.ViewProbeTicket, Patience, ct);
}
public async Task<(ViewProbe Probe, ViewRefusal Refusal)> Knock(bool v6, string id, List<int> ports, CancellationToken ct)
public async Task<WebResult<ViewProbe>> 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));
return await Send(HttpMethod.Post, origin + Endpoints.Probe.POST_M_Run(id), form, MeWebJsonContext.Default.ViewProbe, TimeSpan.FromSeconds(30), ct);
}
public async Task<(ViewShared Shared, ViewRefusal Refusal)> Share(string json, CancellationToken ct)
public async Task<WebResult<ViewShared>> 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);
return await Send(HttpMethod.Post, Endpoints.Base + Endpoints.Share.POST_Create, content, MeWebJsonContext.Default.ViewShared, Patience, ct);
}
public async Task<bool> Unshare(string id, string token, CancellationToken ct)
@@ -93,23 +93,28 @@ public class ServerProbe(HttpClient https, HttpClient plain)
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
async Task<WebResult<T>> Send<T>(HttpMethod method, string url, HttpContent content, System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> type, TimeSpan patience, CancellationToken ct) where T : class
{
var result = new WebResult<T>();
try
{
using var request = new HttpRequestMessage(method, url) { Content = content };
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(patience ?? Patience);
timeout.CancelAfter(patience);
using var response = await https.SendAsync(request, timeout.Token);
if (response.IsSuccessStatusCode) return (await response.Content.ReadFromJsonAsync(type, timeout.Token), null);
if (response.IsSuccessStatusCode)
{
result.Data = await response.Content.ReadFromJsonAsync(type, timeout.Token);
return result;
}
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}" });
return result.Invalidate(refusal ?? new ViewRefusal { Reason = $"http-{(int)response.StatusCode}" }, (int)response.StatusCode);
}
catch (Exception) when (!ct.IsCancellationRequested)
{
return (null, new ViewRefusal { Reason = "unreachable" });
return result.Invalidate("unreachable");
}
}
+4 -4
View File
@@ -409,9 +409,9 @@ public static class Verdicts
}
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.Args = [speed.DownMbps.ToOneDecimal(), speed.UpMbps.ToOneDecimal()];
finding.WithKey("FactDownload", "ValueMbpsDecimal", speed.DownMbps.ToOneDecimal());
finding.WithKey("FactUpload", "ValueMbpsDecimal", speed.UpMbps.ToOneDecimal());
finding.WithKey("FactSample", "ValueSample", (speed.DownBytes / 1048576).ToString(CultureInfo.InvariantCulture), (speed.UpBytes / 1048576).ToString(CultureInfo.InvariantCulture));
if (speed.RoundTripMs > 0) finding.WithKey("FactRoundTrip", "ValueMilliseconds", speed.RoundTripMs.ToString(CultureInfo.InvariantCulture));
return finding;
@@ -430,7 +430,7 @@ public static class Verdicts
};
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));
finding.WithKey("FactMemory", "ValueGb", Math.Round(device.MemoryMb / 1024.0, 1).ToOneDecimal());
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;