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
6 changes: 5 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@
2.14.0 stops firing the state machine for ordinary text, which is most of a connect screen.
A probe of a 2.4 KB screen falls from 10.9 MB allocated to 3.1 MB. Churn, not retention: the
live set was flat throughout, which is why nothing here ever measured a leak.

3.0.0 inflates a stream started with MCCP v1's marker, which both Children of the Night servers
send in answer to DO COMPRESS2; before it, everything after their connect screens, MSSP report
included, arrived as zlib read as text.
-->
<PackageVersion Include="TelnetNegotiationCore" Version="2.14.0" />
<PackageVersion Include="TelnetNegotiationCore" Version="3.0.0" />

<!-- Logging abstractions (TelnetNegotiationCore takes an ILogger). -->
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
Expand Down
5 changes: 4 additions & 1 deletion src/MUI.Catalog/Persistence/Fields/FieldRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ public static class CapabilityFields
/// Two vocabularies name the same capability differently: the handshake negotiates the telnet
/// option (<c>MCCP2</c>), MSSP's variable is the version-less <c>MCCP</c>. Without this alias
/// each spelling gets its own row, and the point of holding measured beside declared — the
/// comparison — cannot happen across two rows.
/// comparison — cannot happen across two rows. <c>MCCP1</c> is the same stream announced with
/// v1's marker, observed at <c>176.9.151.147:7702</c> (Children of the Night), which starts it
/// that way in answer to our <c>DO COMPRESS2</c>.
/// </para>
/// <para>
/// <c>SSL</c> is the same shape between two MSSP variables rather than two protocols: the spec
Expand All @@ -49,6 +51,7 @@ public static class CapabilityFields
private static readonly IReadOnlyDictionary<string, string> Aliases =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["mccp1"] = "MCCP",
["mccp2"] = "MCCP",
["ssl"] = "TLS",
};
Expand Down
8 changes: 8 additions & 0 deletions src/MUI.Crawl/LoginFlow/LoginCommandReading.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ public static partial class LoginCommandReading
["tbamud"] = "tbaMUD",
["rom"] = "ROM",
["merc"] = "Merc",

// The Merc → GodWars line's released codebases. Children of the Night declares both, one per
// version -- CODEBASE "Dystopia 1.4" at 176.9.151.147:7702 (5.0) and "Mindcloud3" at
// omen.genesismuds.com:2251 (4.5) -- and takes whatever reaches its name prompt as a character
// name, INFO included; knowing the words is what lets MsspSelfDescription stop typing at it.
// FamilyWord allows a digit after the word, so "mindcloud" matches "Mindcloud3".
["dystopia"] = "Dystopia",
["mindcloud"] = "Mindcloud",
};

/// <summary>
Expand Down
3 changes: 2 additions & 1 deletion src/MUI.Crawl/Shared/MuLikeness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public static class MuLikeness
/// <remarks>
/// Generic telnet options (TTYPE, NAWS, CHARSET, NEW-ENVIRON, EOR, SUPPRESS GO AHEAD, ECHO) are
/// deliberately excluded — every telnet daemon negotiates them, so admitting one would publish
/// any host that speaks telnet at all. MCCP2/MCCP3 fold into <c>mccp</c> (matching
/// any host that speaks telnet at all. MCCP1/MCCP2/MCCP3 fold into <c>mccp</c> (matching
/// <c>CapabilityFields</c>) because the option is versioned and the capability is not; the alias
/// is repeated rather than shared since <c>MUI.Crawl</c> deliberately has no reference to
/// <c>MUI.Catalog</c>.
Expand All @@ -35,6 +35,7 @@ public static class MuLikeness
["MXP"] = "mxp",
["MSP"] = "msp",
["MCCP"] = "mccp",
["MCCP1"] = "mccp",
["MCCP2"] = "mccp",
["MCCP3"] = "mccp",
["ATCP"] = "atcp",
Expand Down
5 changes: 4 additions & 1 deletion src/MUI.Crawl/Telnet/Negotiation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ public sealed record Negotiation
/// </remarks>
public IReadOnlyList<string> McpPackages { get; init; } = [];

/// <summary>MCCP compression, and which version, when the server negotiated it.</summary>
/// <summary>
/// MCCP compression, and which version, when the server negotiated it. 1 is not a negotiated v1 —
/// the probe refuses that — but a server that answered <c>DO COMPRESS2</c> with v1's marker.
/// </summary>
public int? CompressionVersion { get; init; }

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ public async Task TwoVocabulariesForOneCapabilityArriveAtOneField()
// The handshake names the telnet option it negotiated, MSSP names the feature: MCCP2 against
// MCCP, SSL against TLS. Left apart, each produces a half-empty row that reads as absence.
await Assert.That(CapabilityFields.Canonical("MCCP2")).IsEqualTo("MCCP");
await Assert.That(CapabilityFields.Canonical("MCCP1")).IsEqualTo("MCCP");
await Assert.That(CapabilityFields.Canonical("SSL")).IsEqualTo("TLS");
await Assert.That(CapabilityFields.Canonical("ssl")).IsEqualTo("TLS");

Expand Down
27 changes: 27 additions & 0 deletions tests/MUI.Crawl.Tests/Mssp/MsspSelfDescriptionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,33 @@ public async Task AMudlibTheReaderKnowsCountsAsTheEngine()
await Assert.That(MsspSelfDescription.AnswersTheLoginCommands(report)).IsTrue();
}

/// <summary>
/// Dystopia and Mindcloud are engines in the sense the gate means, and a game that says so is not
/// typed at.
/// </summary>
/// <remarks>
/// Both Children of the Night servers publish these reports — abridged — and read whatever arrives
/// at their name prompt as a character name: <c>INFO</c> came back as "Your sure that you want INFO
/// engraved on your tombstone (Y/N)?", with <c>VERSION</c> then taken as the answer. 5.0, at
/// <c>176.9.151.147:7702</c>, declares <c>Dystopia 1.4</c>; 4.5, at
/// <c>omen.genesismuds.com:2251</c>, declares <c>Mindcloud3</c>. Both are the Merc → GodWars
/// line's own released codebases, in the same sense <c>Merc</c> and <c>ROM</c> already on the list
/// are; the report had already said everything either command could.
/// </remarks>
[Test]
[Arguments("Dystopia 1.4", "DikuMUD")]
[Arguments("Mindcloud3", "Diku")]
public async Task AGodWarsLineGameThatNamedItselfIsNotAskedAgain(string codebase, string family)
{
var report = Report(
("NAME", ["Children of the Night"]),
("CODEBASE", [codebase]),
("FAMILY", [family]),
("PLAYERS", ["0"]));

await Assert.That(MsspSelfDescription.AnswersTheLoginCommands(report)).IsTrue();
}

[Test]
public async Task AMudlibOutsideTheFamilyListLeavesTheQuestionsWorthAsking()
{
Expand Down
16 changes: 16 additions & 0 deletions tests/MUI.Crawl.Tests/Shared/MuLikenessTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,22 @@ public async Task SignalsAreReportedInOneOrderSoTheStoredRecordIsStable()
await Assert.That(MuLikeness.Signals(probe)).IsEquivalentTo(["mssp", "gmcp", "mccp", "who"]);
}

/// <summary>
/// A stream started with MCCP v1's marker is MCCP, whichever marker announced it.
/// </summary>
/// <remarks>
/// <c>176.9.151.147:7702</c> answers <c>DO COMPRESS2</c> with v1's marker, and the probe records
/// the version the wire named. Unfolded, <c>MCCP1</c> would be a telnet option this list does not
/// know, and the one MU*-only signal the session carried would be dropped.
/// </remarks>
[Test]
public async Task AStreamAnnouncedWithTheV1MarkerIsStillMccp()
{
var probe = Probe(offered: ["MCCP1"]);

await Assert.That(MuLikeness.Signals(probe)).IsEquivalentTo(["mccp"]);
}

private static ProbeResult Probe(
MsspOutcome msspOutcome = MsspOutcome.NotOffered,
string[]? offered = null,
Expand Down
130 changes: 130 additions & 0 deletions tests/MUI.Crawl.Tests/Telnet/ProbeSessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,34 @@ await Assert.That(LoginCommandReading.MeaningfulCodebase(result.Info, result.Ver
await Assert.That(LoginCommandReading.ConnectedPlayers(result.Info)).IsEqualTo(7);
}

/// <summary>
/// A server that answers <c>DO COMPRESS2</c> with MCCP v1's marker is still read.
/// </summary>
/// <remarks>
/// Children of the Night (<c>176.9.151.147:7702</c>) offers both versions, is told
/// <c>DONT COMPRESS</c> and <c>DO COMPRESS2</c>, and starts its stream behind
/// <c>IAC SB COMPRESS WILL SE</c> anyway. Before TelnetNegotiationCore 2.17.0 that marker was
/// skipped as an unknown subnegotiation and the zlib behind it reached the page as text: its
/// connect screen ended in a line of binary noise, and its MSSP report, sent inside the stream,
/// never arrived at all.
/// </remarks>
[Test]
public async Task AStreamStartedWithTheV1MarkerIsInflated()
{
await using var game = new Mccp1Game();

var result = await new TelnetProbe(Fast()).ProbeAsync(game.Target);

await Assert.That(result.Outcome).IsEqualTo(ProbeOutcome.Answered);
await Assert.That(result.OfferedOptions).Contains("MCCP1");
await Assert.That(result.Negotiation.CompressionVersion).IsEqualTo(1);

// The report only exists inside the compressed stream, so reading it proves the inflation.
await Assert.That(result.MsspField("NAME")).IsEqualTo("Children of the Night");
await Assert.That(result.Banner).Contains("What name shall we carve into your forehead?");
await Assert.That(result.Banner).DoesNotContain("\uFFFD");
}

/// <summary>
/// And the unterminated line survives compression too, which is what the newline was for.
/// </summary>
Expand Down Expand Up @@ -2423,6 +2451,108 @@ public async ValueTask DisposeAsync()
}
}

/// <summary>
/// Children of the Night's handshake, byte for byte, from a raw socket.
/// </summary>
/// <remarks>
/// Hand-written rather than TelnetNegotiationCore in Server mode, because the library's server
/// only ever sends MCCP2's marker, and the shape under test is a server that does not: this one
/// offers <c>COMPRESS</c> and <c>COMPRESS2</c>, prints its connect screen in the clear, and on
/// <c>DO COMPRESS2</c> starts one zlib stream behind v1's <c>IAC SB COMPRESS WILL SE</c> — the
/// order and bytes captured off the live server on 2026-09-11. Its MSSP report goes inside the
/// stream, where the real one's does.
/// </remarks>
private sealed class Mccp1Game : IAsyncDisposable
{
private const byte Iac = 255, Will = 251, Do = 253, Sb = 250, Se = 240;
private const byte Compress = 85, Compress2 = 86, Mssp = 70, MsspVar = 1, MsspVal = 2;

private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource _stopping = new();
private readonly Task _serving;

public Mccp1Game()
{
_listener.Start();
_serving = ServeAsync();
}

public ProbeTarget Target => new(
IPAddress.Loopback.ToString(),
((IPEndPoint)_listener.LocalEndpoint).Port);

private async Task ServeAsync()
{
try
{
using var client = await _listener.AcceptTcpClientAsync(_stopping.Token);
var stream = client.GetStream();

await stream.WriteAsync(Encoding.ASCII.GetBytes("\n\rAttempting to detect client, please wait...\r\n\r\n")
.Concat(new[] { Iac, Will, Compress })
.Concat(Encoding.ASCII.GetBytes(
"::Children of the Night 4.5::\n\r\n\rWhat name shall we carve into your forehead? "))
.Concat(new[] { Iac, Will, Mssp, Iac, Will, Compress2 })
.ToArray(), _stopping.Token);

// Both answers, however the client splits them across writes.
var heard = new List<byte>();
var buffer = new byte[512];
while (!(Contains(heard, [Iac, Do, Compress2]) && Contains(heard, [Iac, Do, Mssp])))
{
var read = await stream.ReadAsync(buffer, _stopping.Token);
if (read == 0)
{
return;
}

heard.AddRange(buffer.AsSpan(0, read).ToArray());
}

await stream.WriteAsync(new byte[] { Iac, Sb, Compress, Will, Se }, _stopping.Token);

await using var deflate = new System.IO.Compression.ZLibStream(
stream, System.IO.Compression.CompressionLevel.Optimal, leaveOpen: true);
deflate.Write([Iac, Sb, Mssp]);
foreach (var (variable, value) in new[] { ("NAME", "Children of the Night"), ("PLAYERS", "3") })
{
deflate.Write([MsspVar, .. Encoding.ASCII.GetBytes(variable), MsspVal, .. Encoding.ASCII.GetBytes(value)]);
}

deflate.Write([Iac, Se]);
await deflate.FlushAsync(_stopping.Token);

// Hold the session open, reading and discarding, until the probe hangs up.
while (await stream.ReadAsync(buffer, _stopping.Token) > 0)
{
}
}
catch (OperationCanceledException)
{
}
catch (IOException)
{
}
catch (SocketException)
{
}
catch (ObjectDisposedException)
{
}
}

private static bool Contains(List<byte> haystack, byte[] needle) =>
System.Runtime.InteropServices.CollectionsMarshal.AsSpan(haystack).IndexOf(needle) >= 0;

public async ValueTask DisposeAsync()
{
await _stopping.CancelAsync();
_listener.Stop();
await _serving;
_stopping.Dispose();
}
}

/// <summary>
/// The server side of an MSDP round trip, built from TelnetNegotiationCore's own Server-mode
/// interpreter rather than a hand-rolled reimplementation of MSDP's wire framing.
Expand Down