Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions src/MUI.Crawl/LoginCommandReading.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
namespace MUI.Crawl;

/// <summary>
/// Reads pre-login command replies (<c>INFO</c>, <c>VERSION</c>) conservatively.
/// </summary>
/// <remarks>
/// 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 <c>Version:</c> or
/// <c>Codebase:</c>) or when a <c>VERSION</c> line clearly names a known family.
/// </remarks>
public static class LoginCommandReading
{
private static readonly string[] CodebaseLabels = ["codebase", "server", "engine", "family"];
private static readonly string[] VersionLabels = ["version", "release"];
private static readonly IReadOnlyDictionary<string, string> FamilyNames = new Dictionary<string, string>(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",
};

/// <summary>
/// The best codebase/version hint from login-screen command replies, or null.
/// </summary>
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<string> 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<string> lines)
{
foreach (var line in lines)
{
foreach (var (marker, canonical) in FamilyNames)
{
if (line.Contains(marker, StringComparison.OrdinalIgnoreCase))
{
return canonical;
}
}
}

return null;
}
}
8 changes: 7 additions & 1 deletion src/MUI.Crawl/ProbeResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,19 @@ public sealed record ProbeResult
/// <summary>Layer 2 — the connect screen, ANSI intact. Display asset and codebase fingerprint both.</summary>
public string? Banner { get; init; }

/// <summary>Layer 3 — what <c>WHO</c> or <c>DOING</c> yielded at the login screen.</summary>
/// <summary>Layer 3 — what login-screen commands yielded.</summary>
/// <remarks>
/// Defaults to <see cref="WhoReading.NotAsked"/> rather than to an unreadable answer, so a probe
/// that failed before it could ask does not claim to have tried.
/// </remarks>
public WhoReading Who { get; init; } = WhoReading.NotAsked;

/// <summary>The reply to <c>INFO</c> at the login screen, when one arrived.</summary>
public string? Info { get; init; }

/// <summary>The reply to <c>VERSION</c> at the login screen, when one arrived.</summary>
public string? Version { get; init; }

/// <summary>
/// Layer 4 — MSSP as the server reported it over telnet option 70. Every variable, every value,
/// in wire order.
Expand Down
26 changes: 21 additions & 5 deletions src/MUI.Crawl/TelnetProbe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace MUI.Crawl;
/// <para>
/// <b>This client never authenticates.</b> 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 <c>WHO</c> the TinyMUD family answers before login.
/// publishes for crawlers, and the pre-login commands games may answer before login.
/// <see cref="PermittedCommands"/> is the complete list of what may go on the wire, and
/// <c>connect</c> and <c>create</c> are not on it — enforced by test, not by good intentions.
/// </para>
Expand All @@ -29,8 +29,10 @@ namespace MUI.Crawl;
/// </remarks>
public sealed class TelnetProbe(ProbeOptions? options = null, ILogger? logger = null) : IProbe
{
/// <summary>The one question this probe exists to ask.</summary>
/// <summary>The login-screen commands this probe is allowed to ask.</summary>
public const string WhoCommand = "WHO";
public const string InfoCommand = "INFO";
public const string VersionCommand = "VERSION";

/// <summary>
/// Every command this probe is allowed to send. Anything that logs in, creates a character, or
Expand All @@ -48,7 +50,7 @@ public sealed class TelnetProbe(ProbeOptions? options = null, ILogger? logger =
/// implement it simply ignores.
/// </para>
/// </remarks>
public static readonly IReadOnlyList<string> PermittedCommands = [WhoCommand];
public static readonly IReadOnlyList<string> PermittedCommands = [WhoCommand, InfoCommand, VersionCommand];

private const byte Iac = 255;
private const byte Do = 253;
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/MUI.Discovery/IdentityMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
6 changes: 3 additions & 3 deletions src/MUI.Web/Components/AboutPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ public sealed record AboutPage(string Lede, IReadOnlyList<AboutSection> 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."),
Expand Down Expand Up @@ -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.";
}

/// <summary>Whether a directory was actually read, which is not the same as whether we can read it.</summary>
Expand Down
75 changes: 75 additions & 0 deletions tests/MUI.Crawl.Tests/LoginCommandReadingTests.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading