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

+181
View File
@@ -0,0 +1,181 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using MeWeb.Shared;
using Microsoft.Extensions.Options;
namespace MeWeb.Server;
public class ProbeOptions
{
public List<string> Refuse { get; set; } = [];
public bool AllowPrivate { get; set; }
public int PerHour { get; set; } = 6;
public int PerDay { get; set; } = 30;
}
public class ProbeService(IOptionsMonitor<ProbeOptions> options, ILogger<ProbeService> log)
{
static readonly TimeSpan TicketLife = TimeSpan.FromSeconds(60);
readonly ConcurrentDictionary<string, Ticket> tickets = new();
readonly ConcurrentDictionary<string, List<DateTimeOffset>> issued = new();
readonly ConcurrentDictionary<string, byte> running = new();
readonly SemaphoreSlim outbound = new(32, 32);
public (ViewProbeTicket Ticket, ViewRefusal Refusal, int Status) Issue(IPAddress caller)
{
var refusal = Refuse(caller);
if (refusal is not null) return (null, new ViewRefusal { Reason = refusal }, StatusCodes.Status403Forbidden);
var now = DateTimeOffset.UtcNow;
foreach (var (id, old) in tickets)
if (old.Expires < now) tickets.TryRemove(id, out _);
var network = NetworkOf(caller);
var times = issued.GetOrAdd(network, _ => []);
lock (times)
{
times.RemoveAll(t => now - t > TimeSpan.FromDays(1));
var lastHour = times.Count(t => now - t < TimeSpan.FromHours(1));
if (lastHour >= options.CurrentValue.PerHour || times.Count >= options.CurrentValue.PerDay)
{
var retry = lastHour >= options.CurrentValue.PerHour ? times.Where(t => now - t < TimeSpan.FromHours(1)).Min().AddHours(1) : times.Min().AddDays(1);
return (null, new ViewRefusal { Reason = "too-many", RetryAfterSeconds = (int)Math.Ceiling((retry - now).TotalSeconds) }, StatusCodes.Status429TooManyRequests);
}
times.Add(now);
}
var ticket = new Ticket
{
Id = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(12)),
Nonce = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(12)),
Key = RandomNumberGenerator.GetBytes(32),
Address = caller,
Expires = now + TicketLife,
};
tickets[ticket.Id] = ticket;
return (new ViewProbeTicket { Id = ticket.Id, Nonce = ticket.Nonce, Key = Convert.ToBase64String(ticket.Key), Address = caller.ToString(), Seconds = (int)TicketLife.TotalSeconds }, null, StatusCodes.Status200OK);
}
public async Task<(ViewProbe Probe, ViewRefusal Refusal, int Status)> Run(string id, IPAddress caller, ProbeForm form, CancellationToken ct)
{
if (id is null || !tickets.TryRemove(id, out var ticket) || ticket.Expires < DateTimeOffset.UtcNow)
return (null, new ViewRefusal { Reason = "unknown-ticket" }, StatusCodes.Status404NotFound);
if (!ticket.Address.Equals(caller)) return (null, new ViewRefusal { Reason = "other-address" }, StatusCodes.Status403Forbidden);
var ports = form?.Ports.Distinct().ToList() ?? [];
if (ports.Count is 0 or > 3 || ports.Any(p => p is not (80 or 443) and not (>= 49152 and <= 65535)))
return (null, new ViewRefusal { Reason = "bad-ports" }, StatusCodes.Status400BadRequest);
var network = NetworkOf(caller);
if (!running.TryAdd(network, 0)) return (null, new ViewRefusal { Reason = "busy" }, StatusCodes.Status409Conflict);
try
{
var result = new ViewProbe { Address = caller.ToString() };
foreach (var port in ports) result.Ports.Add(new ViewProbePort { Port = port, Outcome = await Knock(ticket, port, ct) });
log.LogInformation("{Service} ran {Count} port(s)", nameof(ProbeService), ports.Count);
return (result, null, StatusCodes.Status200OK);
}
finally
{
running.TryRemove(network, out _);
}
}
async Task<ProbeOutcome> Knock(Ticket ticket, int port, CancellationToken ct)
{
await outbound.WaitAsync(ct);
try
{
using var client = new TcpClient(ticket.Address.AddressFamily);
using (var connect = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
connect.CancelAfter(TimeSpan.FromSeconds(3));
try
{
await client.ConnectAsync(ticket.Address, port, connect.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return ProbeOutcome.TimedOut;
}
catch (SocketException ex) when (ex.SocketErrorCode is SocketError.ConnectionRefused)
{
return ProbeOutcome.Refused;
}
catch (SocketException)
{
return ProbeOutcome.TimedOut;
}
}
using var answer = CancellationTokenSource.CreateLinkedTokenSource(ct);
answer.CancelAfter(TimeSpan.FromSeconds(2));
try
{
var stream = client.GetStream();
await stream.WriteAsync(Encoding.ASCII.GetBytes($"{ProbeProof.Greeting} {ticket.Nonce} {port}\n"), answer.Token);
var buffer = new byte[96];
var length = 0;
while (length < buffer.Length && Array.IndexOf(buffer, (byte)'\n', 0, length) < 0)
{
var read = await stream.ReadAsync(buffer.AsMemory(length), answer.Token);
if (read == 0) break;
length += read;
}
var reply = Encoding.ASCII.GetString(buffer, 0, length).Trim();
var expected = ProbeProof.For(ticket.Key, ticket.Nonce, port);
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(reply), Encoding.ASCII.GetBytes(expected)) ? ProbeOutcome.Reached : ProbeOutcome.OpenNotYou;
}
catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException && !ct.IsCancellationRequested)
{
return ProbeOutcome.OpenNotYou;
}
}
finally
{
outbound.Release();
}
}
string Refuse(IPAddress caller)
{
var settings = options.CurrentValue;
if (!settings.AllowPrivate && !IsPublic(caller)) return "not-public";
foreach (var text in settings.Refuse)
if (IPNetwork.TryParse(text, out var network) && network.Contains(caller)) return "own-network";
return null;
}
static bool IsPublic(IPAddress address)
{
if (IPAddress.IsLoopback(address) || address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || address.IsIPv6UniqueLocal || address.IsIPv6Multicast) return false;
if (address.AddressFamily == AddressFamily.InterNetworkV6) return (address.GetAddressBytes()[0] & 0xe0) == 0x20;
var b = address.GetAddressBytes();
return b switch
{
[0 or 10 or 127, ..] => false,
[100, >= 64 and <= 127, ..] => false,
[169, 254, ..] or [192, 168, ..] or [172, >= 16 and <= 31, ..] => false,
[>= 224, ..] => false,
_ => true,
};
}
public static string NetworkOf(IPAddress address)
{
if (address.AddressFamily != AddressFamily.InterNetworkV6) return address.ToString();
var bytes = address.GetAddressBytes();
Array.Clear(bytes, 8, 8);
return new IPAddress(bytes) + "/64";
}
sealed class Ticket
{
public string Id { get; set; }
public string Nonce { get; set; }
public byte[] Key { get; set; }
public IPAddress Address { get; set; }
public DateTimeOffset Expires { get; set; }
}
}