diff --git a/src/MUI.Crawl/LoginCommandReading.cs b/src/MUI.Crawl/LoginCommandReading.cs new file mode 100644 index 0000000..4b9eb99 --- /dev/null +++ b/src/MUI.Crawl/LoginCommandReading.cs @@ -0,0 +1,185 @@ +namespace MUI.Crawl; + +/// +/// Reads pre-login command replies (INFO, VERSION) conservatively. +/// +/// +/// These commands are intentionally free-form and vary by codebase and by game configuration, so this +/// reader only returns a value when the text explicitly labels one (for example Version: or +/// Codebase:) or when a VERSION line clearly names a known family. +/// +public static class LoginCommandReading +{ + private static readonly string[] CodebaseLabels = ["codebase", "server", "engine", "family"]; + private static readonly string[] VersionLabels = ["version", "release"]; + private static readonly IReadOnlyDictionary FamilyNames = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["pennmush"] = "PennMUSH", + ["rhostmush"] = "RhostMUSH", + ["tinymush"] = "TinyMUSH", + ["tinymux"] = "TinyMUX", + ["aresmush"] = "AresMUSH", + ["cobramush"] = "CobraMUSH", + ["muck"] = "MUCK", + ["tinymuck"] = "TinyMUCK", + ["mudos"] = "MudOS", + ["fluffos"] = "FluffOS", + ["lpmud"] = "LPMud", + ["moo"] = "MOO", + ["evennia"] = "Evennia", + ["coffeemud"] = "CoffeeMUD", + ["smaug"] = "SMAUG", + ["dikumud"] = "DikuMUD", + ["diku"] = "DikuMUD", + ["circlemud"] = "CircleMUD", + ["tbamud"] = "tbaMUD", + ["rom"] = "ROM", + ["merc"] = "Merc", + }; + + /// + /// The best codebase/version hint from login-screen command replies, or null. + /// + public static string? MeaningfulCodebase(string? info, string? version) + { + return FromLabelledValue(info) + ?? FromLabelledValue(version) + ?? FromUnlabelledVersion(version); + } + + private static string? FromLabelledValue(string? text) + { + var lines = Lines(text).ToArray(); + var family = FamilyFrom(lines); + + foreach (var line in lines) + { + if (!TrySplitLabelled(line, out var label, out var value)) + { + continue; + } + + if (CodebaseLabels.Contains(label, StringComparer.OrdinalIgnoreCase)) + { + var named = Clean(value); + if (named is not null) + { + return named; + } + } + + if (VersionLabels.Contains(label, StringComparer.OrdinalIgnoreCase)) + { + var release = Clean(value); + if (release is null) + { + continue; + } + + if (MentionsKnownFamily(release)) + { + return release; + } + + if (family is not null && ContainsDigit(release)) + { + return $"{family} {release}"; + } + + if (ContainsDigit(release)) + { + return release; + } + } + } + + return null; + } + + private static string? FromUnlabelledVersion(string? version) + { + foreach (var line in Lines(version)) + { + var value = Clean(line); + if (value is null) + { + continue; + } + + if (MentionsKnownFamily(value) && ContainsDigit(value)) + { + return value; + } + } + + return null; + } + + private static IEnumerable Lines(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + yield break; + } + + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim(); + if (line.Length == 0) + { + continue; + } + + // Rhost-style wrappers. + if (line.StartsWith("### Begin ", StringComparison.OrdinalIgnoreCase) + || line.StartsWith("### End ", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + yield return line; + } + } + + private static bool TrySplitLabelled(string line, out string label, out string value) + { + var at = line.IndexOf(':'); + if (at <= 0 || at == line.Length - 1) + { + label = string.Empty; + value = string.Empty; + return false; + } + + label = line[..at].Trim(); + value = line[(at + 1)..].Trim(); + return label.Length > 0 && value.Length > 0; + } + + private static string? Clean(string value) + { + var trimmed = value.Trim(); + return MsspDefaults.IsPlaceholder(trimmed) ? null : trimmed; + } + + private static bool ContainsDigit(string value) => value.Any(char.IsDigit); + + private static bool MentionsKnownFamily(string value) => + FamilyNames.Keys.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase)); + + private static string? FamilyFrom(IEnumerable lines) + { + foreach (var line in lines) + { + foreach (var (marker, canonical) in FamilyNames) + { + if (line.Contains(marker, StringComparison.OrdinalIgnoreCase)) + { + return canonical; + } + } + } + + return null; + } +} diff --git a/src/MUI.Crawl/ProbeResult.cs b/src/MUI.Crawl/ProbeResult.cs index 741d431..f3b170e 100644 --- a/src/MUI.Crawl/ProbeResult.cs +++ b/src/MUI.Crawl/ProbeResult.cs @@ -34,13 +34,19 @@ public sealed record ProbeResult /// Layer 2 — the connect screen, ANSI intact. Display asset and codebase fingerprint both. public string? Banner { get; init; } - /// Layer 3 — what WHO or DOING yielded at the login screen. + /// Layer 3 — what login-screen commands yielded. /// /// Defaults to rather than to an unreadable answer, so a probe /// that failed before it could ask does not claim to have tried. /// public WhoReading Who { get; init; } = WhoReading.NotAsked; + /// The reply to INFO at the login screen, when one arrived. + public string? Info { get; init; } + + /// The reply to VERSION at the login screen, when one arrived. + public string? Version { get; init; } + /// /// Layer 4 — MSSP as the server reported it over telnet option 70. Every variable, every value, /// in wire order. diff --git a/src/MUI.Crawl/TelnetProbe.cs b/src/MUI.Crawl/TelnetProbe.cs index dbd508f..29f0b09 100644 --- a/src/MUI.Crawl/TelnetProbe.cs +++ b/src/MUI.Crawl/TelnetProbe.cs @@ -17,7 +17,7 @@ namespace MUI.Crawl; /// /// This client never authenticates. Everything it reads is what a server hands an anonymous /// connection: the banner it sends unprompted, the options it negotiates, the MSSP report it -/// publishes for crawlers, and the pre-login WHO the TinyMUD family answers before login. +/// publishes for crawlers, and the pre-login commands games may answer before login. /// is the complete list of what may go on the wire, and /// connect and create are not on it — enforced by test, not by good intentions. /// @@ -29,8 +29,10 @@ namespace MUI.Crawl; /// public sealed class TelnetProbe(ProbeOptions? options = null, ILogger? logger = null) : IProbe { - /// The one question this probe exists to ask. + /// The login-screen commands this probe is allowed to ask. public const string WhoCommand = "WHO"; + public const string InfoCommand = "INFO"; + public const string VersionCommand = "VERSION"; /// /// Every command this probe is allowed to send. Anything that logs in, creates a character, or @@ -48,7 +50,7 @@ public sealed class TelnetProbe(ProbeOptions? options = null, ILogger? logger = /// implement it simply ignores. /// /// - public static readonly IReadOnlyList PermittedCommands = [WhoCommand]; + public static readonly IReadOnlyList PermittedCommands = [WhoCommand, InfoCommand, VersionCommand]; private const byte Iac = 255; private const byte Do = 253; @@ -126,17 +128,29 @@ int Arrived() await SettleAsync(telnet, Arrived, bannerLines, _options.QuietPeriod, budget.Token); var flushLines = Arrived(); - // Phase 3 — the one question we are allowed to ask. SendAsync appends the line ending + // Phase 3 — the first question we are allowed to ask. SendAsync appends the line ending // itself, so the command is handed over bare. await telnet.SendAsync(Encoding.ASCII.GetBytes(WhoCommand)); await SettleAsync(telnet, Arrived, flushLines, _options.SilenceGrace, budget.Token); var whoLines = Arrived(); - string banner, whoText; + // Phase 4 — INFO at the login screen. + await telnet.SendAsync(Encoding.ASCII.GetBytes(InfoCommand)); + await SettleAsync(telnet, Arrived, whoLines, _options.SilenceGrace, budget.Token); + var infoLines = Arrived(); + + // Phase 5 — VERSION at the login screen. + await telnet.SendAsync(Encoding.ASCII.GetBytes(VersionCommand)); + await SettleAsync(telnet, Arrived, infoLines, _options.SilenceGrace, budget.Token); + var versionLines = Arrived(); + + string banner, whoText, infoText, versionText; lock (lines) { banner = string.Join("\n", lines.Take(bannerLines)); whoText = string.Join("\n", lines.Skip(flushLines).Take(whoLines - flushLines)); + infoText = string.Join("\n", lines.Skip(whoLines).Take(infoLines - whoLines)); + versionText = string.Join("\n", lines.Skip(infoLines).Take(versionLines - infoLines)); } if (telnet.CurrentEncoding is not null) @@ -156,6 +170,8 @@ int Arrived() Negotiation = seen.ToNegotiation(), Banner = banner, Who = new WhoParser().Parse(whoText), + Info = infoText.Length == 0 ? null : infoText, + Version = versionText.Length == 0 ? null : versionText, BannerPlayerCount = BannerCount.Find(banner), Mssp = viaOption ? MsspReport.From(seen.Mssp) : MsspReport.Empty, MsspOutcome = seen.MsspOutcome, diff --git a/src/MUI.Discovery/IdentityMatcher.cs b/src/MUI.Discovery/IdentityMatcher.cs index 201523c..8584302 100644 --- a/src/MUI.Discovery/IdentityMatcher.cs +++ b/src/MUI.Discovery/IdentityMatcher.cs @@ -184,7 +184,8 @@ private sealed record Observation( MsspReading.Meaningful(result.Mssp, IdentityMsspVariables.Created), MsspReading.Meaningful(result.Mssp, IdentityMsspVariables.Website), MsspReading.Meaningful(result.Mssp, IdentityMsspVariables.Contact), - MsspReading.Meaningful(result.Mssp, IdentityMsspVariables.Codebase), + MsspReading.Meaningful(result.Mssp, IdentityMsspVariables.Codebase) + ?? LoginCommandReading.MeaningfulCodebase(result.Info, result.Version), FingerprintOf(result.Banner), ClaimTokenBeacon.Read(result)); diff --git a/src/MUI.Web/Components/AboutPage.cs b/src/MUI.Web/Components/AboutPage.cs index 3ca2b85..0ca9558 100644 --- a/src/MUI.Web/Components/AboutPage.cs +++ b/src/MUI.Web/Components/AboutPage.cs @@ -146,8 +146,8 @@ public sealed record AboutPage(string Lede, IReadOnlyList Sections [ new("A probe is one connection that never logs in.", "It opens a socket, negotiates telnet options, reads whatever connect screen the " - + "server paints, asks for MSSP by negotiating option 70, sends a single " - + $"{string.Join(" or ", TelnetProbe.PermittedCommands)} at the connect screen, and " + + "server paints, asks for MSSP by negotiating option 70, sends " + + $"{string.Join(", ", TelnetProbe.PermittedCommands)} at the connect screen, and " + "disconnects. It creates no character, sends no login, and changes nothing on the " + "far side. The whole session is bounded by a timeout so a wedged probe cannot sit " + "on a server's connection slot."), @@ -328,7 +328,7 @@ public sealed record AboutIdentity(string Name, string InfoUrl, bool Announced, + "what reaches your logs is that library's own default, and a NEW-ENVIRON request is " + "answered from the crawler host's environment rather than with anything about us. Both " + "are gaps in the library and both are ours to fix there. Until they are fixed, the way to " - + "recognise a probe is its shape: one connection, no login, one WHO, gone."; + + "recognise a probe is its shape: one connection, no login, a short read-only command set, gone."; } /// Whether a directory was actually read, which is not the same as whether we can read it. diff --git a/tests/MUI.Crawl.Tests/LoginCommandReadingTests.cs b/tests/MUI.Crawl.Tests/LoginCommandReadingTests.cs new file mode 100644 index 0000000..0dfa7db --- /dev/null +++ b/tests/MUI.Crawl.Tests/LoginCommandReadingTests.cs @@ -0,0 +1,75 @@ +using MUI.Crawl; + +namespace MUI.Crawl.Tests; + +public class LoginCommandReadingTests +{ + [Test] + public async Task ALabelledInfoVersionValueIsRead() + { + var info = """ + ### Begin INFO 1 + Name: Convergence MUSH + Uptime: Tue Sep 16 23:39:43 2025 + Connected: 60 + Size: 1929 + Version: RhostMUSH 4.27.3 + ### End INFO + """; + + var read = LoginCommandReading.MeaningfulCodebase(info, null); + + await Assert.That(read).IsEqualTo("RhostMUSH 4.27.3"); + } + + [Test] + public async Task ALabelledCodebaseFieldWinsWhenPresent() + { + var info = "Codebase: TinyMUX 2.13"; + + var read = LoginCommandReading.MeaningfulCodebase(info, "Version: ignored"); + + await Assert.That(read).IsEqualTo("TinyMUX 2.13"); + } + + [Test] + public async Task AnUnlabelledVersionLineWithKnownFamilyIsRead() + { + var version = """ + TinyMUX 2.14.0.4 #22 + Copyright 1995-2026 TinyMUX Team + """; + + var read = LoginCommandReading.MeaningfulCodebase(null, version); + + await Assert.That(read).IsEqualTo("TinyMUX 2.14.0.4 #22"); + } + + [Test] + public async Task AFamilyHeadingCanPrefixANumericVersionField() + { + var version = """ + TinyMUSH Engine + --------------- + Version : 4.0 stable + """; + + var read = LoginCommandReading.MeaningfulCodebase(null, version); + + await Assert.That(read).IsEqualTo("TinyMUSH 4.0 stable"); + } + + [Test] + public async Task GenericInfoWithoutCodebaseHintsReturnsNull() + { + var info = """ + Name: Convergence MUSH + Connected: 60 + Size: 1929 + """; + + var read = LoginCommandReading.MeaningfulCodebase(info, null); + + await Assert.That(read).IsNull(); + } +} diff --git a/tests/MUI.Crawl.Tests/ProbeRestraintTests.cs b/tests/MUI.Crawl.Tests/ProbeRestraintTests.cs index 8561e5b..3168dcd 100644 --- a/tests/MUI.Crawl.Tests/ProbeRestraintTests.cs +++ b/tests/MUI.Crawl.Tests/ProbeRestraintTests.cs @@ -29,12 +29,14 @@ public async Task TheProbeNeverLogsInAndNeverCreatesACharacter() } [Test] - public async Task TheOnlyThingItAsksForIsWho() + public async Task TheOnlyThingsItAsksForAreThePreLoginReadOnlyCommands() { // A short list is the point. Every addition here is a new way to affect a stranger's server, // so it should be hard to grow and obvious when it does. - await Assert.That(TelnetProbe.PermittedCommands).Count().IsEqualTo(1); - await Assert.That(TelnetProbe.PermittedCommands[0]).IsEqualTo("WHO"); + await Assert.That(TelnetProbe.PermittedCommands).Count().IsEqualTo(3); + await Assert.That(TelnetProbe.PermittedCommands).Contains("WHO"); + await Assert.That(TelnetProbe.PermittedCommands).Contains("INFO"); + await Assert.That(TelnetProbe.PermittedCommands).Contains("VERSION"); } [Test] diff --git a/tests/MUI.Crawl.Tests/ProbeSessionTests.cs b/tests/MUI.Crawl.Tests/ProbeSessionTests.cs index 67211bc..bf3d02e 100644 --- a/tests/MUI.Crawl.Tests/ProbeSessionTests.cs +++ b/tests/MUI.Crawl.Tests/ProbeSessionTests.cs @@ -115,6 +115,25 @@ public async Task WhatTheServerSaysBackToOurOwnFlushIsCountedAsNeither() await Assert.That(result.Who.Count).IsEqualTo(5); } + [Test] + public async Task InfoAndVersionRepliesAreCapturedSeparately() + { + await using var game = new FakeGame + { + Banner = "Welcome to Nowhere\r\n", + WhoReply = "There are 5 players connected.\r\n", + InfoReply = "Codebase: CorvidMUSH\r\n", + VersionReply = "Version 1.2.3\r\n", + }; + + var result = await new TelnetProbe(Fast()).ProbeAsync(game.Target); + + await Assert.That(result.Who.Count).IsEqualTo(5); + await Assert.That(result.Info).IsEqualTo("Codebase: CorvidMUSH"); + await Assert.That(result.Version).IsEqualTo("Version 1.2.3"); + await Assert.That(result.Banner).DoesNotContain("Codebase: CorvidMUSH"); + } + [Test] public async Task AServerThatBuffersOurNegotiationAsTextStillAnswersWho() { @@ -136,7 +155,7 @@ public async Task AServerThatBuffersOurNegotiationAsTextStillAnswersWho() } [Test] - public async Task TheProbeNeverSendsAnythingButItsOnePermittedCommand() + public async Task TheProbeNeverSendsAnythingButItsPermittedCommands() { // The restraint test with a wire under it. Everything the probe types must be the permitted // command or an empty line; nothing that logs in, creates or changes anything — and, since @@ -265,6 +284,8 @@ public FakeGame() public string? BannerTail { get; init; } public string WhoReply { get; init; } = string.Empty; + public string? InfoReply { get; init; } + public string? VersionReply { get; init; } /// The tail of the WHO reply, unterminated. public string? WhoTail { get; init; } @@ -394,6 +415,26 @@ private async Task HandleAsync(NetworkStream stream, string line) { await SendAsync(stream, WhoTail); } + + return; + } + + if (command.Equals("INFO", StringComparison.OrdinalIgnoreCase)) + { + if (InfoReply is not null) + { + await SendAsync(stream, InfoReply); + } + + return; + } + + if (command.Equals("VERSION", StringComparison.OrdinalIgnoreCase)) + { + if (VersionReply is not null) + { + await SendAsync(stream, VersionReply); + } } } diff --git a/tests/MUI.Discovery.Tests/IdentityMatcherTests.cs b/tests/MUI.Discovery.Tests/IdentityMatcherTests.cs index e5ba504..308f6cd 100644 --- a/tests/MUI.Discovery.Tests/IdentityMatcherTests.cs +++ b/tests/MUI.Discovery.Tests/IdentityMatcherTests.cs @@ -127,6 +127,29 @@ await world.GameAsync( await Assert.That(verdict).IsTypeOf(); } + [Test] + public async Task CodebaseCanComeFromInfoWhenMsspCodebaseIsAbsent() + { + var world = new IdentityWorld(); + await world.GameAsync( + (IdentityFields.Codebase, "RhostMUSH 4.27.3"), + (IdentityFields.Website, "https://convergence.example")); + + var verdict = await world.Matcher.ResolveAsync(ProbeResults.Answered( + host: "new.example.org", + mssp: ProbeResults.Mssp(("WEBSITE", "https://convergence.example")), + info: """ + ### Begin INFO 1 + Name: Convergence MUSH + Version: RhostMUSH 4.27.3 + ### End INFO + """), None); + + await Assert.That(verdict).IsTypeOf(); + await Assert.That(((IdentityVerdict.Review)verdict).Score.Score) + .IsEqualTo(IdentityWeights.WebsiteOrContact + IdentityWeights.CodebaseAndVersion); + } + [Test] public async Task TheThresholdsAreConfigurableBecauseTheyNeedCalibrating() { diff --git a/tests/MUI.Discovery.Tests/Support/ProbeResults.cs b/tests/MUI.Discovery.Tests/Support/ProbeResults.cs index 29c3518..7917e51 100644 --- a/tests/MUI.Discovery.Tests/Support/ProbeResults.cs +++ b/tests/MUI.Discovery.Tests/Support/ProbeResults.cs @@ -44,6 +44,8 @@ public static ProbeResult Answered( IReadOnlyDictionary>? mssp = null, string? banner = null, WhoReading? who = null, + string? info = null, + string? version = null, DateTimeOffset? at = null) => new() { Host = host, @@ -56,6 +58,8 @@ public static ProbeResult Answered( MsspOutcome = mssp is null ? MsspOutcome.NotOffered : MsspOutcome.Received, MsspTransport = mssp is null ? MsspTransport.None : MsspTransport.TelnetOption70, Banner = banner, + Info = info, + Version = version, // NotAsked rather than an unreadable answer, for the same reason: a fixture that says nothing // about WHO must not claim we asked and could not read the reply. Who = who ?? WhoReading.NotAsked,