diff --git a/src/Inbix.Core/Abstractions/IAliasResolver.cs b/src/Inbix.Core/Abstractions/IAliasResolver.cs index bfe057e..18a5953 100644 --- a/src/Inbix.Core/Abstractions/IAliasResolver.cs +++ b/src/Inbix.Core/Abstractions/IAliasResolver.cs @@ -8,4 +8,7 @@ public interface IAliasResolver { /// True if the full address is a known, enabled alias on an accepted domain. Task IsDeliverableAsync(string address, CancellationToken ct = default); + + /// Drop cached deliverability so alias create/enable/disable/delete take effect immediately. + void Invalidate(); } diff --git a/src/Inbix.Data/Json/JsonDataStore.cs b/src/Inbix.Data/Json/JsonDataStore.cs index 1a650cd..3617f7e 100644 --- a/src/Inbix.Data/Json/JsonDataStore.cs +++ b/src/Inbix.Data/Json/JsonDataStore.cs @@ -169,11 +169,12 @@ private async Task LoadMessagesAsync(string dir, Dictionary if (m is null) continue; m.CurrentPath = file; - // De-duplicate by id (a crash mid-move can leave two copies): keep the newest, delete the older. + // De-duplicate by id (a crash mid-move can leave two copies). Prefer the copy whose *content* + // is more advanced — parsing/junk state — rather than filesystem mtime, which is coarse and can + // even run backwards across servers on NFS. if (messages.TryGetValue(m.Id, out var existing)) { - var keepNew = File.GetLastWriteTimeUtc(file) >= File.GetLastWriteTimeUtc(existing.CurrentPath!); - if (keepNew) { _io.TryDelete(existing.CurrentPath!); messages[m.Id] = m; } + if (PreferCandidate(m, file, existing)) { _io.TryDelete(existing.CurrentPath!); messages[m.Id] = m; } else { _io.TryDelete(file); } } else @@ -183,6 +184,15 @@ private async Task LoadMessagesAsync(string dir, Dictionary } } + private static bool PreferCandidate(StoredMessage candidate, string candidateFile, StoredMessage existing) + { + if (candidate.Parsed != existing.Parsed) return candidate.Parsed; // parsing only adds data + var c = candidate.StateChangedAt ?? DateTimeOffset.MinValue; + var e = existing.StateChangedAt ?? DateTimeOffset.MinValue; + if (c != e) return c > e; // latest junk/unjunk/sweep wins + return File.GetLastWriteTimeUtc(candidateFile) >= File.GetLastWriteTimeUtc(existing.CurrentPath!); + } + private async Task> LoadAuditAsync(CancellationToken ct) { var list = new List(); @@ -303,7 +313,10 @@ internal string AllocateFolderName(string localPart, string domain, bool isCatch var withDomain = SafeFolder($"{localPart}@{domain}"); if (!taken.Contains(withDomain)) return withDomain; - return $"{baseName}-{Guid.NewGuid():N}"[..Math.Min(baseName.Length + 9, 40)]; + string candidate; + do { candidate = $"{baseName}-{Guid.NewGuid():N}"[..Math.Min(baseName.Length + 9, 40)]; } + while (taken.Contains(candidate)); + return candidate; } private static readonly HashSet WindowsReserved = new(StringComparer.OrdinalIgnoreCase) diff --git a/src/Inbix.Data/Services/AliasService.cs b/src/Inbix.Data/Services/AliasService.cs index 7e59046..98e75f0 100644 --- a/src/Inbix.Data/Services/AliasService.cs +++ b/src/Inbix.Data/Services/AliasService.cs @@ -13,18 +13,21 @@ public sealed class AliasService : IAliasService { private readonly IAliasRepository _aliases; private readonly IMessageRepository _messages; + private readonly IAliasResolver _resolver; private readonly ILogger _logger; - public AliasService(IAliasRepository aliases, IMessageRepository messages, ILogger logger) + public AliasService(IAliasRepository aliases, IMessageRepository messages, IAliasResolver resolver, ILogger logger) { _aliases = aliases; _messages = messages; + _resolver = resolver; _logger = logger; } public async Task CreateAsync(string localPart, string domain, string? notes, CancellationToken ct = default) { var alias = await _aliases.CreateAsync(localPart, domain, notes, ct).ConfigureAwait(false); + _resolver.Invalidate(); // the new alias must be deliverable immediately (no 30s cache lag) var migrated = 0; var catchAll = await _aliases.GetCatchAllAsync(ct).ConfigureAwait(false); @@ -52,6 +55,7 @@ public async Task DeleteAsync(long aliasId, CancellationToken ct = default) migrated = await _messages.ReassignAllAsync(aliasId, catchAll.Id, ct).ConfigureAwait(false); await _aliases.DeleteAsync(aliasId, ct).ConfigureAwait(false); + _resolver.Invalidate(); // stop treating the deleted address as deliverable _logger.LogInformation("Deleted alias {Address}; moved {Count} message(s) to the catch-all", alias.Address, migrated); return migrated; } diff --git a/src/Inbix.Data/Services/CachingAliasResolver.cs b/src/Inbix.Data/Services/CachingAliasResolver.cs index d981cd5..7c91265 100644 --- a/src/Inbix.Data/Services/CachingAliasResolver.cs +++ b/src/Inbix.Data/Services/CachingAliasResolver.cs @@ -19,7 +19,10 @@ public sealed class CachingAliasResolver : IAliasResolver private readonly IAliasRepository _aliases; private readonly HashSet _domains; private readonly ConcurrentDictionary _cache = new(); - private (bool enabled, DateTimeOffset expires) _catchAll; + // A reference field published with volatile semantics — a tuple field would be a torn read across the + // concurrent RCPT threads (it's wider than a machine word). + private sealed record CatchAllState(bool Enabled, DateTimeOffset Expires); + private volatile CatchAllState? _catchAll; public CachingAliasResolver(IAliasRepository aliases, IOptions options) { @@ -62,12 +65,19 @@ public async Task IsDeliverableAsync(string address, CancellationToken ct private async Task CatchAllEnabledAsync(DateTimeOffset now, CancellationToken ct) { - if (_catchAll.expires > now) - return _catchAll.enabled; + var cached = _catchAll; + if (cached is not null && cached.Expires > now) + return cached.Enabled; var catchAll = await _aliases.GetCatchAllAsync(ct).ConfigureAwait(false); var enabled = catchAll is { Enabled: true }; - _catchAll = (enabled, now.Add(Ttl)); + _catchAll = new CatchAllState(enabled, now.Add(Ttl)); return enabled; } + + public void Invalidate() + { + _cache.Clear(); + _catchAll = null; + } } diff --git a/src/Inbix.Data/Sqlite/SqliteConnectionFactory.cs b/src/Inbix.Data/Sqlite/SqliteConnectionFactory.cs index 1909ffe..5970a99 100644 --- a/src/Inbix.Data/Sqlite/SqliteConnectionFactory.cs +++ b/src/Inbix.Data/Sqlite/SqliteConnectionFactory.cs @@ -35,6 +35,7 @@ public sealed class SqliteConnectionFactory : IDbConnectionFactory, IAsyncDispos // Exclusive mode only: serialise all access through one shared connection. private readonly SemaphoreSlim _gate = new(1, 1); + private static readonly TimeSpan GateTimeout = TimeSpan.FromSeconds(60); private SqliteConnection? _shared; public SqliteConnectionFactory(IOptions options) @@ -83,7 +84,12 @@ public async Task OpenConnectionAsync(CancellationToken ct = defau // Exclusive mode: acquire the gate, ensure the shared connection is healthy, and hand out a // non-owning lease. The caller's dispose releases the gate (see LeasedSqliteConnection). - await _gate.WaitAsync(ct).ConfigureAwait(false); + // A bounded wait turns a re-entrant OpenConnectionAsync (nested lease → self-deadlock) or a stuck + // operation into a clear exception instead of hanging the whole app forever. + if (!await _gate.WaitAsync(GateTimeout, ct).ConfigureAwait(false)) + throw new TimeoutException( + "Timed out waiting for the SQLite exclusive-connection gate. This usually means a re-entrant " + + "OpenConnectionAsync (a repository method that opens a second connection while holding one) or a stuck operation."); try { if (_shared is null || _shared.State != ConnectionState.Open) diff --git a/src/Inbix.Imap/ImapServerHostedService.cs b/src/Inbix.Imap/ImapServerHostedService.cs index 90a2063..a7faae1 100644 --- a/src/Inbix.Imap/ImapServerHostedService.cs +++ b/src/Inbix.Imap/ImapServerHostedService.cs @@ -42,8 +42,15 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } X509Certificate2? certificate = null; - if (!string.IsNullOrWhiteSpace(_options.CertificatePath) && File.Exists(_options.CertificatePath)) + if (!string.IsNullOrWhiteSpace(_options.CertificatePath)) + { + // Fail fast rather than silently serving PLAINTEXT (leaking credentials) when a cert was + // configured but is missing/unloadable. + if (!File.Exists(_options.CertificatePath)) + throw new FileNotFoundException( + $"Inbix:Imap:CertificatePath '{_options.CertificatePath}' was not found. Fix the path, or clear it to serve plaintext intentionally."); certificate = X509CertificateLoader.LoadPkcs12FromFile(_options.CertificatePath, _options.CertificatePassword); + } var listener = new TcpListener(IPAddress.Any, _options.Port); listener.Start(); diff --git a/src/Inbix.Imap/ImapSession.cs b/src/Inbix.Imap/ImapSession.cs index d4b9d11..b92a9b9 100644 --- a/src/Inbix.Imap/ImapSession.cs +++ b/src/Inbix.Imap/ImapSession.cs @@ -22,8 +22,18 @@ public sealed class ImapSession private readonly IInboxNotifier _notifier; private readonly ILogger _logger; + // Read timeouts stop idle/slowloris connections from holding a session slot forever. + private static readonly TimeSpan PreAuthTimeout = TimeSpan.FromSeconds(60); + private static readonly TimeSpan IdleTimeout = TimeSpan.FromMinutes(30); + private const int MaxFailedLogins = 5; + private const int PreAuthLiteralCap = 8 * 1024; // literals before auth are tiny (LOGIN) + private const int PostAuthLiteralCap = 64 * 1024 * 1024; + private bool _authenticated; + private int _failedLogins; + private bool _closing; // set after too many failed logins → drop the connection private string? _selectedName; + private bool _writable; // current selection allows \Deleted/EXPUNGE (SELECT + AllowDelete, not EXAMINE) private List _selected = []; private readonly HashSet _deleted = []; // message ids flagged \Deleted this session (AllowDelete) @@ -47,7 +57,20 @@ public async Task RunAsync(CancellationToken ct) while (!ct.IsCancellationRequested) { - var line = await ReadCommandAsync(ct).ConfigureAwait(false); + string? line; + using (var readCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + readCts.CancelAfter(_authenticated ? IdleTimeout : PreAuthTimeout); + try + { + line = await ReadCommandAsync(readCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + await SendAsync("* BYE idle timeout\r\n", ct).ConfigureAwait(false); // slowloris / idle protection + break; + } + } if (line is null) break; var sp = line.IndexOf(' '); @@ -57,16 +80,23 @@ public async Task RunAsync(CancellationToken ct) try { - if (await DispatchAsync(tag, rest, ct).ConfigureAwait(false)) break; // LOGOUT + if (await DispatchAsync(tag, rest, ct).ConfigureAwait(false)) break; // LOGOUT / forced close } catch (Exception ex) when (ex is not OperationCanceledException) { - _logger.LogWarning(ex, "IMAP command failed: {Command}", rest); + // Log only the command verb, never the arguments (they can contain a LOGIN password). + _logger.LogWarning(ex, "IMAP command failed: {Command}", Verb(rest)); await SendAsync($"{tag} BAD internal error\r\n", ct).ConfigureAwait(false); } } } + private static string Verb(string rest) + { + var sp = rest.IndexOf(' '); + return sp < 0 ? rest : rest[..sp]; + } + private static string Capabilities() => "IMAP4rev1 IDLE AUTH=PLAIN"; private async Task DispatchAsync(string tag, string rest, CancellationToken ct) @@ -88,10 +118,10 @@ private async Task DispatchAsync(string tag, string rest, CancellationToke return true; case "LOGIN": await LoginAsync(tag, tokens, ct).ConfigureAwait(false); - return false; + return _closing; case "AUTHENTICATE": await AuthenticateAsync(tag, tokens, ct).ConfigureAwait(false); - return false; + return _closing; } if (!_authenticated) @@ -121,11 +151,14 @@ private async Task DispatchAsync(string tag, string rest, CancellationToke case "EXPUNGE": await ExpungeAsync(tag, restrictUids: null, byUid: false, ct).ConfigureAwait(false); break; case "IDLE": - await IdleAsync(tag, ct).ConfigureAwait(false); break; + if (await IdleAsync(tag, ct).ConfigureAwait(false)) return true; // idle timeout → drop + break; case "CLOSE": - if (_options.AllowDelete && _selectedName is not null) - await DoExpungeAsync(silent: true, restrictUids: null, ct).ConfigureAwait(false); // CLOSE expunges silently - _selectedName = null; _selected = []; _deleted.Clear(); + // Only expunge on CLOSE when the mailbox was opened read-write (SELECT + AllowDelete). + // A read-only (EXAMINE) mailbox must never delete on close. + if (_writable && _selectedName is not null) + await DoExpungeAsync(silent: true, restrictUids: null, ct).ConfigureAwait(false); + _selectedName = null; _selected = []; _deleted.Clear(); _writable = false; await SendAsync($"{tag} OK CLOSE completed\r\n", ct).ConfigureAwait(false); break; case "SUBSCRIBE": case "UNSUBSCRIBE": @@ -177,12 +210,17 @@ private async Task FinishAuthAsync(string tag, string user, string pass, Cancell if (Verify(user, pass)) { _authenticated = true; + _failedLogins = 0; await SendAsync($"{tag} OK LOGIN completed\r\n", ct).ConfigureAwait(false); + return; } - else + + await Task.Delay(300, ct).ConfigureAwait(false); // throttle + await SendAsync($"{tag} NO [AUTHENTICATIONFAILED] Invalid credentials\r\n", ct).ConfigureAwait(false); + if (++_failedLogins >= MaxFailedLogins) { - await Task.Delay(300, ct).ConfigureAwait(false); // token throttle - await SendAsync($"{tag} NO [AUTHENTICATIONFAILED] Invalid credentials\r\n", ct).ConfigureAwait(false); + await SendAsync("* BYE too many failed login attempts\r\n", ct).ConfigureAwait(false); + _closing = true; // drop the connection to blunt brute-forcing } } @@ -230,7 +268,8 @@ private static System.Text.RegularExpressions.Regex PatternRegex(string pattern) { // IMAP wildcards: * matches anything (incl. hierarchy), % matches within one level. var escaped = System.Text.RegularExpressions.Regex.Escape(pattern).Replace("\\*", ".*").Replace("%", "[^/]*"); - return new System.Text.RegularExpressions.Regex("^" + escaped + "$", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + return new System.Text.RegularExpressions.Regex("^" + escaped + "$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100)); // guard against ReDoS } private async Task SelectAsync(string tag, List tokens, bool examine, CancellationToken ct) @@ -246,6 +285,7 @@ private async Task SelectAsync(string tag, List tokens, bool examine, Ca // SELECT is read-write only when deletes are allowed; EXAMINE is always read-only. var writable = _options.AllowDelete && !examine; + _writable = writable; var uidNext = (_selected.Count > 0 ? _selected[^1].Id : 0) + 1; var sb = new StringBuilder(); sb.Append(writable ? "* FLAGS (\\Seen \\Deleted)\r\n" : "* FLAGS (\\Seen)\r\n"); @@ -386,11 +426,13 @@ private async Task SearchAsync(string tag, List tokens, bool byUid, Canc private async Task StoreAsync(string tag, List tokens, bool byUid, CancellationToken ct) { + if (_selectedName is null) { await SendAsync($"{tag} NO No mailbox selected\r\n", ct).ConfigureAwait(false); return; } var targets = Resolve(tokens.Count > 1 ? tokens[1] : "", byUid); - if (!_options.AllowDelete) + // \Deleted is only honoured on a read-write selection (SELECT + AllowDelete, not EXAMINE); otherwise + // STORE is a no-op that just echoes \Seen so clients don't error. + if (!_writable) { - // Read-only: accept but persist nothing. Echo \Seen back so clients don't error. foreach (var (seq, _) in targets) await SendAsync($"* {seq} FETCH (FLAGS (\\Seen))\r\n", ct).ConfigureAwait(false); await SendAsync($"{tag} OK {(byUid ? "UID " : "")}STORE completed\r\n", ct).ConfigureAwait(false); @@ -428,9 +470,10 @@ private async Task StoreAsync(string tag, List tokens, bool byUid, Cance private async Task ExpungeAsync(string tag, HashSet? restrictUids, bool byUid, CancellationToken ct) { if (_selectedName is null) { await SendAsync($"{tag} NO No mailbox selected\r\n", ct).ConfigureAwait(false); return; } - if (!_options.AllowDelete) + if (!_writable) { - await SendAsync($"{tag} NO [CANNOT] Inbix mailboxes are read-only (set Inbix:Imap:AllowDelete to enable)\r\n", ct).ConfigureAwait(false); + // Read-only (or EXAMINE, or AllowDelete off): never expunge. + await SendAsync($"{tag} NO [CANNOT] Mailbox is read-only (open with SELECT and set Inbix:Imap:AllowDelete to enable deletes)\r\n", ct).ConfigureAwait(false); return; } await DoExpungeAsync(silent: false, restrictUids, ct).ConfigureAwait(false); @@ -463,22 +506,26 @@ private async Task DoExpungeAsync(bool silent, HashSet? restrictUids, Canc // ---- IDLE ---- - private async Task IdleAsync(string tag, CancellationToken ct) + // Returns true when the connection should be dropped (idle timeout). + private async Task IdleAsync(string tag, CancellationToken ct) { - if (_selectedName is null) { await SendAsync($"{tag} BAD No mailbox selected\r\n", ct).ConfigureAwait(false); return; } + if (_selectedName is null) { await SendAsync($"{tag} BAD No mailbox selected\r\n", ct).ConfigureAwait(false); return false; } var signal = Channel.CreateUnbounded(); void OnEvent(InboxEvent _) => signal.Writer.TryWrite(true); _notifier.Received += OnEvent; await SendAsync("+ idling\r\n", ct).ConfigureAwait(false); + + using var idleCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + idleCts.CancelAfter(IdleTimeout); // don't let an IDLE connection hold a slot forever + var readDone = ReadLineAsync(idleCts.Token); + var timedOut = false; try { - var readDone = ReadLineAsync(ct); while (true) { var wake = signal.Reader.WaitToReadAsync(ct).AsTask(); - var done = await Task.WhenAny(readDone, wake).ConfigureAwait(false); - if (done == readDone) break; // client sent DONE (or disconnected) + if (await Task.WhenAny(readDone, wake).ConfigureAwait(false) == readDone) break; // client sent DONE / disconnected / timed out while (signal.Reader.TryRead(out _)) { } var refreshed = await _mailboxes.GetMessagesAsync(_selectedName, ct).ConfigureAwait(false); @@ -488,12 +535,17 @@ private async Task IdleAsync(string tag, CancellationToken ct) await SendAsync($"* {_selected.Count} EXISTS\r\n", ct).ConfigureAwait(false); } } + try { await readDone.ConfigureAwait(false); } // observe the read (avoid unobserved exception) + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { timedOut = true; } } finally { _notifier.Received -= OnEvent; } + + if (timedOut) { await SendAsync("* BYE idle timeout\r\n", ct).ConfigureAwait(false); return true; } await SendAsync($"{tag} OK IDLE terminated\r\n", ct).ConfigureAwait(false); + return false; } // ---- Sequence / item parsing ---- @@ -649,7 +701,10 @@ private static List Tokenize(string s) var inner = line[(brace + 1)..^1]; var nonSync = inner.EndsWith('+'); if (nonSync) inner = inner[..^1]; - if (int.TryParse(inner, out var n) && n >= 0 && n <= 64 * 1024 * 1024) + // Cap literals — tiny before auth (only LOGIN needs one) — so an unauthenticated peer + // can't force a large allocation. An over-cap literal falls through and the command is rejected. + var literalCap = _authenticated ? PostAuthLiteralCap : PreAuthLiteralCap; + if (int.TryParse(inner, out var n) && n >= 0 && n <= literalCap) { sb.Append(line[..brace]); if (!nonSync) await SendAsync("+ Ready for literal\r\n", ct).ConfigureAwait(false); diff --git a/src/Inbix.Web/Program.cs b/src/Inbix.Web/Program.cs index 088f72d..9da1863 100644 --- a/src/Inbix.Web/Program.cs +++ b/src/Inbix.Web/Program.cs @@ -69,7 +69,10 @@ options.SlidingExpiration = true; options.Cookie.Name = "inbix.auth"; options.Cookie.HttpOnly = true; - options.Cookie.SameSite = SameSiteMode.Lax; + // Strict (not Lax) so the session cookie is never sent on ANY cross-site request — closes CSRF on + // the route-only admin POSTs (backup/reload/reindex, junk/unjunk) incl. the Lax+POST window. Trade-off: + // following an external link into the UI shows the login page until you navigate within the app. + options.Cookie.SameSite = SameSiteMode.Strict; options.Cookie.SecurePolicy = requireHttps ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest; }); diff --git a/tests/Inbix.Tests/DataLayerTests.cs b/tests/Inbix.Tests/DataLayerTests.cs index 414baee..c583f41 100644 --- a/tests/Inbix.Tests/DataLayerTests.cs +++ b/tests/Inbix.Tests/DataLayerTests.cs @@ -101,6 +101,21 @@ public async Task Disabled_Alias_Is_Not_Deliverable() Assert.False(await fresh.IsDeliverableAsync("github@mydomain.com")); } + [Fact] + public async Task Creating_Alias_Invalidates_Resolver_Negative_Cache() + { + var resolver = _sp.GetRequiredService(); + var aliasService = _sp.GetRequiredService(); + + // Prime the negative cache: not deliverable yet, and the "no" is cached for the TTL. + Assert.False(await resolver.IsDeliverableAsync("fresh@mydomain.com")); + + await aliasService.CreateAsync("fresh", "mydomain.com", null); + + // Without cache invalidation this would stay false for up to 30s; the new alias must accept mail now. + Assert.True(await resolver.IsDeliverableAsync("fresh@mydomain.com")); + } + public Task DisposeAsync() => Task.CompletedTask; public void Dispose() diff --git a/tests/Inbix.Tests/ImapDeleteTests.cs b/tests/Inbix.Tests/ImapDeleteTests.cs index c37a7b5..76bff22 100644 --- a/tests/Inbix.Tests/ImapDeleteTests.cs +++ b/tests/Inbix.Tests/ImapDeleteTests.cs @@ -84,6 +84,34 @@ public async Task Client_Delete_Removes_The_Message_From_The_Server() Assert.Single(await messages.ListByAliasAsync(_aliasId, 50, 0)); } + [Fact] + public async Task Examine_Is_Read_Only_Even_With_AllowDelete() + { + var messages = _host.Services.GetRequiredService(); + Assert.Equal(2, (await messages.ListByAliasAsync(_aliasId, 50, 0)).Count); + + // A non-compliant client marks \Deleted on an EXAMINE (read-only) mailbox and CLOSEs. + using var tcp = new TcpClient(); + await tcp.ConnectAsync(IPAddress.Loopback, _port); + using var r = new StreamReader(tcp.GetStream()); + await using var w = new StreamWriter(tcp.GetStream()) { NewLine = "\r\n", AutoFlush = true }; + await r.ReadLineAsync(); + await Cmd(w, r, "a1 LOGIN admin admin", "a1"); + await Cmd(w, r, "a2 EXAMINE INBOX", "a2"); // read-only open + await Cmd(w, r, "a3 STORE 1 +FLAGS (\\Deleted)", "a3"); + await Cmd(w, r, "a4 CLOSE", "a4"); // must NOT expunge from a read-only mailbox + + Assert.Equal(2, (await messages.ListByAliasAsync(_aliasId, 50, 0)).Count); // nothing deleted + } + + private static async Task Cmd(StreamWriter w, StreamReader r, string command, string tag) + { + await w.WriteLineAsync(command); + string? line; + while ((line = await r.ReadLineAsync()) is not null) + if (line.StartsWith(tag + " ", StringComparison.Ordinal)) break; + } + private static int FreePort() { var l = new TcpListener(IPAddress.Loopback, 0);