diff --git a/CLAUDE.md b/CLAUDE.md index 6f66ecc6..250361fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,13 @@ our host is unreachable and perfectly alive. typed at the family above, every crawl** — issue #182 is where that is being thought about, and it is not the same problem: there is nothing they would publish, so "not asking must imply publishing" has nothing to hold on to. +- **Type `MSSP-REQUEST` at an address that has reported over option 70, or that left its trial + unanswered.** The plaintext form is text at a login prompt, and eight of twenty surveyed games read + it as a character name. `crawl_target.mssp_route` (migration 0042) remembers each address's answer + and `MsspRoutes` is the one place that reads it: an unknown address gets **one** trial, on a dial + of its own after the measurement (`PlaintextMsspAsk.Trial`), so a name prompt that turns the request + into a password prompt never gets that session's `WHO` typed into it. A plaintext report is stored + as `mssp` like any other report, and is never noted as an option-70 negotiation. - **Read a roster as a total, or fold it into `mssp`.** `MsspCountKind.Roster` is a **floor**: `tdome.nukefire.org:4000` states `PLAYERS = 70` and names sixty-nine, stably, because every codebase has someone it does not show. It gets its own `mssp_roster` source so a reader can tell diff --git a/docs/codebase-survey-2026-07-30.md b/docs/codebase-survey-2026-07-30.md index 7ca61f45..306d896d 100644 --- a/docs/codebase-survey-2026-07-30.md +++ b/docs/codebase-survey-2026-07-30.md @@ -222,10 +222,13 @@ the entire time. ## The plaintext `MSSP-REQUEST` form — 3 answered of 20, and 8 read it as a name -**Measured, and deliberately not implemented here.** The plaintext form belongs in -TelnetNegotiationCore, where it is filed as **issue #61**; `CLAUDE.md`'s rule is that a gap in that -library is a PR rather than a compensating hack in this repository. This section is the evidence for -whoever writes that PR — the probe carries none of it. +**Measured here; now asked, narrowly.** The plaintext form belongs in TelnetNegotiationCore, where +it was filed as **issue #61** and shipped as `MSSPPlaintextProtocol` (4.0). The probe now uses it under +the policy this section argues for: never to an address that has reported over option 70, once to +every other address on a dial of its own after the measurement, every session only to an address +that answered, and never again to one that did not (`crawl_target.mssp_route`, migration 0042; +`MsspRoutes`). The cost below is why it is once and on its own dial: a name prompt that turns the +request into a password prompt must not get the measuring session's `WHO` typed into it. Twenty games were sent the literal line `MSSP-REQUEST` at their login screen. Three answered with a well-formed `MSSP-REPLY-START` / tab-separated / `MSSP-REPLY-END` report: diff --git a/migrations/0042_mssp_route.sql b/migrations/0042_mssp_route.sql new file mode 100644 index 00000000..0f45a3af --- /dev/null +++ b/migrations/0042_mssp_route.sql @@ -0,0 +1,59 @@ +-- crawl_target.mssp_route: which way, if any, this address hands over its MSSP report. +-- +-- The plaintext form — the line `MSSP-REQUEST` typed at the connect screen, answered between +-- `MSSP-REPLY-START` and `MSSP-REPLY-END` — is the SMAUG family's, and TelnetNegotiationCore 4.0 +-- carries it as MSSPPlaintextProtocol. Unlike `IAC DO 70`, which a server without MSSP ignores, it is +-- text at a stranger's login prompt: of twenty games sent it in docs/codebase-survey-2026-07-30.md, +-- three answered and eight read it as a character name. So it is asked at most once of an address +-- that has not answered it, and never of one that reports the proper way. This column is how the +-- crawl loop remembers which is which: +-- +-- NULL nothing known yet. The next session that reaches the game without an option-70 +-- report is followed by one short dial that asks MSSP-REQUEST and nothing else. +-- telnet a report has arrived over option 70. Never asked the plaintext form, ever: the user +-- rule this was built from is "only for games that have not already given us MSSP the +-- proper way". +-- plaintext answered MSSP-REQUEST. Asked again every session, at the connect screen, because it +-- is the only route this address has. +-- none sent MSSP-REQUEST and did not answer it. Never asked again. +-- +-- A measurement of them, unlike crawl_refusal, which is our note about our own decision: whether a +-- server answers a line of the MSSP specification is a fact about the server. It is still kept off +-- every public surface. The game page's MSSP fields carry the report itself, whichever route brought +-- it; how it travelled says nothing a reader needs. +-- +-- Per address, because the probe dials addresses. A game reachable on two ports is asked on each, +-- which is right: nothing makes two listeners run the same code. +-- +-- No BEGIN/COMMIT: MigrationRunner opens its own transaction per script and writes the ledger entry +-- inside it. + +ALTER TABLE crawl_target + ADD COLUMN mssp_route text, + ADD COLUMN mssp_route_at timestamptz, + + ADD CONSTRAINT crawl_target_mssp_route_vocabulary CHECK ( + mssp_route IN ('telnet', 'plaintext', 'none')), + + -- Set together: a route nobody can date is one nobody can judge the age of. + ADD CONSTRAINT crawl_target_mssp_route_is_dated CHECK ( + (mssp_route IS NULL) = (mssp_route_at IS NULL)); + +COMMENT ON COLUMN crawl_target.mssp_route IS + 'How this address hands over MSSP: telnet (option 70, so the plaintext MSSP-REQUEST is never ' + 'sent), plaintext (answers MSSP-REQUEST, asked every session), none (did not answer it, never ' + 'asked again), or NULL (not yet known; gets one trial).'; + +-- Every game that has already reported over option 70 is excused the trial. Until this migration +-- nothing could put a value under these two sources except an option-70 report, so their presence +-- is exactly "has given us MSSP the proper way". Dated now rather than back-dated: this is when the +-- crawl loop learnt it, and the rows' own first_seen_at would claim a knowledge of this column that +-- nobody had. +UPDATE crawl_target t + SET mssp_route = 'telnet', + mssp_route_at = now() + WHERE EXISTS ( + SELECT 1 + FROM game_field f + WHERE f.game_id = t.game_id + AND f.source IN ('mssp', 'mssp_roster')); diff --git a/src/MUI.Crawl/Telnet/ProbeOptions.cs b/src/MUI.Crawl/Telnet/ProbeOptions.cs index b4859771..da13cc4b 100644 --- a/src/MUI.Crawl/Telnet/ProbeOptions.cs +++ b/src/MUI.Crawl/Telnet/ProbeOptions.cs @@ -160,11 +160,18 @@ public sealed record ProbeOptions /// public int MaxPromptRounds { get; init; } = 4; - // Deliberately no option here for the plaintext MSSP-REQUEST form — it belongs in - // TelnetNegotiationCore, and a compensating implementation here would duplicate a first-party - // dependency and then have to be deleted once that lands. Every surveyed game that answered the - // plaintext form also answered telnet option 70, so it reached nothing extra; several others read - // the bare request as a character name and burned a login attempt on it. + /// + /// How long a plaintext MSSP-REQUEST waits for MSSP-REPLY-END before the game is + /// taken not to answer it. + /// + /// + /// Handed to TelnetNegotiationCore's MSSPPlaintextProtocol.WithReplyTimeout. Ten seconds is + /// the library's own default and Grapevine's crawler's window. Every reply measured + /// (docs/codebase-survey-2026-07-30.md) arrived well inside it; a server that has not + /// started one by then has read the request as a character name. Only ever spent where + /// says to ask at all. + /// + public TimeSpan PlaintextMsspGrace { get; init; } = TimeSpan.FromSeconds(10); /// /// Ceiling on a single subnegotiation payload, handed to TelnetNegotiationCore's diff --git a/src/MUI.Crawl/Telnet/ProbeResult.cs b/src/MUI.Crawl/Telnet/ProbeResult.cs index 17a14d6f..18b39d93 100644 --- a/src/MUI.Crawl/Telnet/ProbeResult.cs +++ b/src/MUI.Crawl/Telnet/ProbeResult.cs @@ -141,6 +141,17 @@ public IReadOnlyList MsspValues(string variable) => /// Which transport produced . Part of the value's provenance. public MsspTransport MsspTransport { get; init; } = MsspTransport.None; + /// + /// What came of the plaintext MSSP-REQUEST, when + /// had it sent. + /// + /// + /// Kept apart from , which an unanswered request never changes: a game + /// that ignored the plaintext line has the same it had + /// before it was asked. Read by the crawl loop, to decide whether to ask again. + /// + public PlaintextMsspOutcome PlaintextMssp { get; init; } = PlaintextMsspOutcome.NotAsked; + public FailureDetail? Failure { get; init; } /// @@ -168,7 +179,10 @@ public IReadOnlyList MsspValues(string variable) => /// public enum MsspOutcome { - /// The server never offered MSSP and did not answer the plaintext request. + /// + /// No report arrived: the server never offered MSSP and, if it was sent the plaintext request, + /// did not answer that either. + /// NotOffered, /// A report arrived and was parsed. It may still be empty, which is the server's answer. @@ -194,13 +208,35 @@ public enum MsspTransport /// The plaintext MSSP-REQUEST reply, delimited by START/END markers. /// - /// Nothing produces this yet, deliberately — the plaintext form belongs in - /// TelnetNegotiationCore (first-party), and implementing it here would duplicate then have to be - /// deleted. The member stays because spec §6.4 describes both routes. + /// Read by TelnetNegotiationCore's MSSPPlaintextProtocol; this repository only decides + /// when to ask (). /// PlaintextRequest, } +/// What came of a plaintext MSSP-REQUEST. +public enum PlaintextMsspOutcome +{ + /// + /// The line was never sent: nobody asked for it, the session negotiated option 70, or the + /// connection was gone first. Says nothing about the game. + /// + NotAsked, + + /// + /// The game answered with an MSSP-REPLY-START to MSSP-REPLY-END block, including + /// one too large to keep, which is still an answer. + /// + Answered, + + /// + /// The line went out and no reply came back before + /// ran out or the game hung up. A measurement of the game, and the one that stops it being asked + /// again. + /// + Unanswered, +} + public enum ProbeOutcome { Answered, diff --git a/src/MUI.Crawl/Telnet/Probing.cs b/src/MUI.Crawl/Telnet/Probing.cs index a071a921..4c6f857b 100644 --- a/src/MUI.Crawl/Telnet/Probing.cs +++ b/src/MUI.Crawl/Telnet/Probing.cs @@ -82,9 +82,63 @@ public sealed record ProbeTarget(string Host, int Port) /// public bool UseTls { get; init; } + /// + /// Whether, and how, to send the plaintext MSSP-REQUEST line this time. + /// + /// + /// + /// Defaults to , the opposite direction from + /// and for the opposite reason: IAC DO 70 is ignored by + /// a server without MSSP, but this is a line of text at somebody's login prompt, and eight of the + /// twenty games surveyed read it as a character name (docs/codebase-survey-2026-07-30.md). + /// A caller that cannot say what this address has done before does not type it. + /// + /// + /// Only the crawl loop answers it, from what the address did before (crawl_target.mssp_route, + /// see MsspRoutes): an address that has ever reported over option 70, or failed to answer + /// this line, is never asked; one that answered it is asked every session; one that has done + /// neither gets one . + /// + /// + public PlaintextMsspAsk PlaintextMssp { get; init; } = PlaintextMsspAsk.Never; + public override string ToString() => Host.Contains(':') ? $"[{Host}]:{Port}" : $"{Host}:{Port}"; } +/// What a probe does about the plaintext MSSP-REQUEST form. +/// +/// The plaintext form is the SMAUG family's (spec §6.4); TelnetNegotiationCore carries it as +/// MSSPPlaintextProtocol. It is never sent in a session that negotiated option 70, whatever +/// this says: a game already reporting the proper way has nothing to add by the older one. +/// +public enum PlaintextMsspAsk +{ + /// Not sent. + Never, + + /// + /// Sent once, on a second short dial of its own, after an ordinary session that got no MSSP. + /// + /// + /// A dial of its own because the measurement session must not pay for the experiment. A game + /// that reads the line as a character name moves on to a password prompt, and every command after + /// it, WHO included, is then typed into the wrong question. That would publish our + /// experiment as an unreadable WHO (rule 5). The trial dial asks for nothing else and hangs + /// up. + /// + Trial, + + /// + /// Sent in the ordinary session, at the connect screen and before WHO, because this address + /// has answered it before. + /// + /// + /// Before WHO on purpose: the report usually carries PLAYERS, which is a count + /// PublishedCountAsync accepts in place of typing WHO at all. + /// + Ask, +} + /// /// What carried a probe session. Part of the result's provenance, and the whole of what /// EndpointKind.Tls is written from. diff --git a/src/MUI.Crawl/Telnet/TelnetProbe.cs b/src/MUI.Crawl/Telnet/TelnetProbe.cs index 2f98b91b..7a9601f3 100644 --- a/src/MUI.Crawl/Telnet/TelnetProbe.cs +++ b/src/MUI.Crawl/Telnet/TelnetProbe.cs @@ -43,8 +43,9 @@ public sealed class TelnetProbe(ProbeOptions? options = null, ILogger? logger = /// /// /// The bare line terminator sent between the banner and WHO is not on this list — it's not - /// a command, it carries no text. MSSP-REQUEST is also absent by design (see - /// ): MSSP is asked for by negotiation, which a server without it ignores. + /// a command, it carries no text. MSSP-REQUEST is not on it either: it is sent only by + /// TelnetNegotiationCore's MSSPPlaintextProtocol, only where + /// says this address may be asked, and never in a session that negotiated option 70. /// The other thing that goes on the wire is a classified prompt answer, which is not a command /// either and is bounded separately by . /// @@ -83,6 +84,27 @@ public static bool IsPermittedPromptAnswer(string? answer) => private readonly ILogger _logger = logger ?? NullLogger.Instance; public async Task ProbeAsync(ProbeTarget target, CancellationToken cancellationToken = default) + { + var result = await MeasureAsync(target, cancellationToken); + + // The plaintext trial runs after the measurement and never inside it — see + // PlaintextMsspAsk.Trial. Only for a session that reached the game and got no MSSP at all: + // a game that negotiated option 70, even one whose report never came or was too large to + // keep, is reporting the proper way and has nothing to add by the older one. + return target.PlaintextMssp is PlaintextMsspAsk.Trial + && result is + { + Outcome: ProbeOutcome.Answered, + MsspOutcome: MsspOutcome.NotOffered, + PlaintextMssp: PlaintextMsspOutcome.NotAsked, + } + && !result.OfferedOptions.Contains("MSSP") + ? await TrialPlaintextMsspAsync(target, result, cancellationToken) + : result; + } + + /// The measurement: one session, and the other transport when the first learnt nothing. + private async Task MeasureAsync(ProbeTarget target, CancellationToken cancellationToken) { var session = await SessionAsync( target, @@ -270,7 +292,7 @@ bool Heard() } var prompts = new PromptSink(lines); - var builder = Build(seen, lines, prompts); + var builder = Build(seen, lines, prompts, target.PlaintextMssp is PlaintextMsspAsk.Ask); // The stream overload and the TcpClient one differ only in where the pipe comes from, so // everything above the socket — negotiation, MSSP, the WHO parser, the encoding decision @@ -323,6 +345,17 @@ int Arrived() // handed to the wrong parameter by a future edit that reorders phases. var cursors = new PhaseCursors { Banner = Arrived() }; + // An address that has answered the plaintext form before is asked it here: after the + // connect screen has settled, so the request lands at the name prompt the SMAUG family + // reads it at, and before WHO, so a PLAYERS in the reply can spare the game the WHO. + // Whatever the line provokes besides the reply (the reply's own leading blank line, a + // re-printed prompt) arrives after cursors.Banner and before cursors.Flush, the slice + // that is thrown away for the same reason the flush line's reaction is: it is a reaction + // to text we sent, not the game's screen. + var plaintext = target.PlaintextMssp is PlaintextMsspAsk.Ask + ? await AskPlaintextMsspAsync(telnet, client, seen, target, budget.Token) + : PlaintextMsspOutcome.NotAsked; + // A who's-online menu option is different from every category the prompt loop above // answers: selecting it doesn't reveal a second screen behind this one — for every real // game measured (BatMUD, ZombieMUD, discworld.starturtle.net) the menu already settled @@ -365,7 +398,9 @@ int Arrived() // has already done the flushing. Computed here rather than read off // whoAlreadyAnswered inside, so that flag keeps its one meaning: if WHO ever comes to // be answered by a route that sends nothing, the flush must not silently go with it. - alreadyFlushed: answeredAPrompt || whoFromMenu is not null, + alreadyFlushed: answeredAPrompt + || whoFromMenu is not null + || plaintext is not PlaintextMsspOutcome.NotAsked, whoAlreadyAnswered: whoFromMenu is not null, markAsked: () => asked = true, target, @@ -385,7 +420,8 @@ int Arrived() return new Session( BuildAnsweredResult( target, observedAt, started, seen, reading, cursors, - whoFromMenu, whoFromMenuShape, asked, published, telnet.CurrentEncoding, transport), + whoFromMenu, whoFromMenuShape, asked, published, telnet.CurrentEncoding, transport, + plaintext), Heard()); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -444,6 +480,200 @@ or ObjectDisposedException } } + /// + /// Sends the plaintext MSSP-REQUEST and waits for the reply, unless this session has + /// already shown it has no need of one. + /// + /// + /// + /// The exchange itself is TelnetNegotiationCore's (MSSPPlaintextProtocol): it frames the + /// request, reads the reply off the line stream so none of it reaches lines as screen, and + /// hands the report to the same OnMSSP callback option 70 uses. This method decides only + /// whether to ask, and what the answer says about the game. + /// + /// + /// covers a hang-up after the line went out as well + /// as silence: a server that ends the session on an unknown name has answered the question as + /// plainly as one that says Illegal name. A socket already gone beforehand is + /// , because then nothing was asked. + /// + /// + private async Task AskPlaintextMsspAsync( + TelnetInterpreter telnet, + TcpClient client, + Observations seen, + ProbeTarget target, + CancellationToken cancellationToken) + { + if (seen.Supported.Contains("MSSP") + || seen.MsspOutcome is not MsspOutcome.NotOffered + || !Live(client) + || telnet.PluginManager?.GetPlugin() is not { } plaintext) + { + return PlaintextMsspOutcome.NotAsked; + } + + seen.AskingPlaintext = true; + + try + { + var report = await plaintext.RequestReportAsync(cancellationToken); + + return report is not null || seen.MsspOutcome is MsspOutcome.RejectedTooLarge + ? PlaintextMsspOutcome.Answered + : PlaintextMsspOutcome.Unanswered; + } + catch (Exception error) when (HungUp(error)) + { + // The write itself failed, so the line never reached them. + _logger.LogDebug( + "{Host}:{Port} was gone before MSSP-REQUEST could be sent ({Error})", + target.Host, target.Port, error.Message); + return PlaintextMsspOutcome.NotAsked; + } + finally + { + seen.AskingPlaintext = false; + } + } + + /// + /// The second, short dial that asks an address the plaintext form for the first time: the + /// connect screen, any pre-screen prompts, MSSP-REQUEST, and nothing else. + /// + /// + /// + /// is returned with the trial's outcome on it and, when the game + /// answered, its report — the same report a third session would have read, minutes earlier than + /// waiting a whole crawl interval for it. + /// + /// + /// The trial can only add to the measurement, never cost it. Anything that goes wrong on + /// this dial — refused, reset, a handshake that will not complete, a defect here — returns + /// unchanged with , which + /// the crawl loop reads as "still to try". Our caller going away is the one exception, rethrown + /// for the same reason rethrows it. + /// + /// + private async Task TrialPlaintextMsspAsync( + ProbeTarget target, + ProbeResult measured, + CancellationToken cancellationToken) + { + using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + budget.CancelAfter(_options.Timeout); + + var lines = new List(); + var seen = new Observations(); + + using var client = new TcpClient(); + SslStream? tunnel = null; + + try + { + await (target.Addresses.Count > 0 + ? client.ConnectAsync([.. target.Addresses], target.Port, budget.Token) + : client.ConnectAsync(target.Host, target.Port, budget.Token)); + + // The door the measurement just came through, not the one the target was dialled with: + // the measurement may have had to try the other one. + if (measured.Transport is ProbeTransport.Tls) + { + tunnel = await HandshakeAsync(client, target, budget.Token); + } + + var prompts = new PromptSink(lines); + var builder = Build(seen, lines, prompts, plaintextMssp: true); + var built = tunnel is null + ? await builder.BuildAndStartAsync(client, budget.Token) + : await builder.BuildAndStartAsync(tunnel, budget.Token); + + prompts.Reads(built.Interpreter); + await using var telnet = built.Interpreter; + _ = ObserveReadLoopAsync(built.ReadTask, target); + + int Arrived() + { + lock (lines) + { + return lines.Count; + } + } + + // The same approach to the name prompt the measurement takes, so the request lands where + // a SMAUG-family nanny reads it — not on a colour question or a press-enter gate. + await SettleInitialBannerAsync(telnet, Arrived, lines, budget.Token); + await AnswerPromptsAsync(telnet, Arrived, lines, client, budget.Token); + + var outcome = await AskPlaintextMsspAsync(telnet, client, seen, target, budget.Token); + + _logger.LogDebug( + "{Host}:{Port} plaintext MSSP-REQUEST: {Outcome}", target.Host, target.Port, outcome); + + if (outcome is not PlaintextMsspOutcome.Answered) + { + return measured with { PlaintextMssp = outcome }; + } + + if (seen.MsspOutcome is MsspOutcome.RejectedTooLarge) + { + return measured with + { + PlaintextMssp = outcome, + MsspOutcome = MsspOutcome.RejectedTooLarge, + MsspBytesRejected = seen.MsspRejectedBytes, + }; + } + + // The report's encoding is decided the way the measurement decides it, against the screen + // it arrived beside — here, this dial's. + WireReading reading; + lock (lines) + { + reading = WireEncoding.Read( + lines, target.Charset, MsspReport.RawValues(seen.Mssp), target.MsspCharset); + } + + return measured with + { + PlaintextMssp = outcome, + Mssp = MsspReport.From(seen.Mssp, reading.MsspEncoding), + MsspOutcome = MsspOutcome.Received, + MsspTransport = MsspTransport.PlaintextRequest, + }; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception error) + { + if (HungUp(error) + || error is SocketException or OperationCanceledException or AuthenticationException) + { + _logger.LogDebug( + "{Host}:{Port} plaintext MSSP trial did not get as far as asking ({Error})", + target.Host, target.Port, error.Message); + } + else + { + _logger.LogWarning( + error, + "{Host}:{Port} plaintext MSSP trial failed; the measurement stands", + target.Host, target.Port); + } + + return measured; + } + finally + { + if (tunnel is not null) + { + await tunnel.DisposeAsync(); + } + } + } + /// /// Opens the TLS tunnel this session will speak telnet inside. /// @@ -635,8 +865,7 @@ async Task AskAsync(string command, int baseline, TimeSpan grace, Action? s try { // MSDP's request vocabulary — SEND, REPORT, LIST, RESET, UNREPORT — has no plaintext - // form, so it is not on PermittedCommands for the same reason MSSP-REQUEST is not: it - // is asked for by protocol, not by typing. Gated on TelnetNegotiationCore 2.9.0's + // form, so it is not on PermittedCommands: it is asked for by protocol, not by typing. Gated on TelnetNegotiationCore 2.9.0's // IsNegotiated (see Watched.Msdp), which reflects the peer's real WILL/DO acceptance — // unlike the pre-2.9.0 OnEnabledAsync, which was true from plugin construction // regardless of the wire (TelnetNegotiationCore#85). By this point in the probe the @@ -883,7 +1112,8 @@ private static ProbeResult BuildAnsweredResult( bool asked, int? published, Encoding? negotiatedEncoding, - ProbeTransport transport) + ProbeTransport transport, + PlaintextMsspOutcome plaintext) { var read = reading.Lines; @@ -916,7 +1146,7 @@ private static ProbeResult BuildAnsweredResult( seen.Charset ??= negotiatedEncoding.WebName; } - var viaOption = seen.MsspOutcome is MsspOutcome.Received; + var received = seen.MsspOutcome is MsspOutcome.Received; return new ProbeResult { @@ -946,10 +1176,11 @@ private static ProbeResult BuildAnsweredResult( // why not asking must imply publishing. Second, not first: the session-wide charset // decision is made above and a screen this decode reads better is read better. BannerPlayerCount = BannerCount.Find(banner) ?? published, - Mssp = viaOption ? MsspReport.From(seen.Mssp, reading.MsspEncoding) : MsspReport.Empty, + Mssp = received ? MsspReport.From(seen.Mssp, reading.MsspEncoding) : MsspReport.Empty, MsspOutcome = seen.MsspOutcome, MsspBytesRejected = seen.MsspRejectedBytes, - MsspTransport = viaOption ? MsspTransport.TelnetOption70 : MsspTransport.None, + MsspTransport = received ? seen.MsspTransport : MsspTransport.None, + PlaintextMssp = plaintext, Transport = transport, Elapsed = Stopwatch.GetElapsedTime(started), }; @@ -1221,7 +1452,11 @@ public ValueTask TakeAsync() /// separate statement apiece; the TelnetNegotiationCore plugin builders are already self-typed /// fluent, so this is the shape the library wants, not a new abstraction layered over it. /// - private TelnetInterpreterBuilder Build(Observations seen, List lines, PromptSink prompts) + private TelnetInterpreterBuilder Build( + Observations seen, + List lines, + PromptSink prompts, + bool plaintextMssp) { void Note(string protocol) => seen.Note(protocol); @@ -1231,7 +1466,20 @@ private TelnetInterpreterBuilder Build(Observations seen, List lines, Pr { seen.Mssp = config; seen.MsspOutcome = MsspOutcome.Received; - Note("MSSP"); + + // The plaintext reply arrives through this same callback, and it is not option 70. + // Supported means "observed active in the handshake", so noting MSSP for a line of + // text would publish a negotiation that never happened as a measured capability. + if (config.Source is MSSPSource.Plaintext) + { + seen.MsspTransport = MsspTransport.PlaintextRequest; + } + else + { + seen.MsspTransport = MsspTransport.TelnetOption70; + Note("MSSP"); + } + return ValueTask.CompletedTask; }) // 2.7.0 drops an oversized report whole rather than truncating it. Kept as its own @@ -1240,7 +1488,13 @@ private TelnetInterpreterBuilder Build(Observations seen, List lines, Pr { seen.MsspOutcome = MsspOutcome.RejectedTooLarge; seen.MsspRejectedBytes = (int)Math.Min(sizes.Item1, int.MaxValue); - Note("MSSP"); + + // The callback does not say which route overran, so the request in flight does. + if (!seen.AskingPlaintext) + { + Note("MSSP"); + } + return ValueTask.CompletedTask; }); @@ -1321,7 +1575,7 @@ private TelnetInterpreterBuilder Build(Observations seen, List lines, Pr // of the TTYPE list. var terminalType = new Watched.TerminalType(Note).WithTerminalTypes([.. _options.TerminalTypes]); - return new TelnetInterpreterBuilder() + var builder = new TelnetInterpreterBuilder() .UseMode(TelnetInterpreter.TelnetMode.Client) .UseLogger(_logger) .WithClientIdentity(_options.TerminalTypes.Count > 0 ? _options.TerminalTypes[0] : "MUINDEX-CRAWLER") @@ -1388,6 +1642,13 @@ private TelnetInterpreterBuilder Build(Observations seen, List lines, Pr .AddPlugin(new PacketPatchProtocol() .WithHoldTime(_options.PromptHold) .OnPrompt(prompts.TakeAsync)); + + // Registered only when this session may ask. On a client the plugin sends nothing until + // RequestReportAsync is called, so this is belt and braces: a session that was not told to + // ask has no way to. + return plaintextMssp + ? builder.AddPlugin(new MSSPPlaintextProtocol().WithReplyTimeout(_options.PlaintextMsspGrace)) + : builder; } /// @@ -1447,6 +1708,10 @@ private sealed class Observations public MSSPConfig? Mssp; public MsspOutcome MsspOutcome = MsspOutcome.NotOffered; + public MsspTransport MsspTransport = MsspTransport.None; + + /// Set while a plaintext MSSP-REQUEST waits for its reply. + public volatile bool AskingPlaintext; public int? MsspRejectedBytes; public string? Charset; public bool Prompts; diff --git a/src/MUI.Crawler/Crawl/CrawlCycle.cs b/src/MUI.Crawler/Crawl/CrawlCycle.cs index ccb13670..2498ff54 100644 --- a/src/MUI.Crawler/Crawl/CrawlCycle.cs +++ b/src/MUI.Crawler/Crawl/CrawlCycle.cs @@ -259,6 +259,10 @@ private async Task AttemptAsync(CrawlTarget target, CancellationToken c // known to be behind a handshake — and the probe overrules it either way if the game // has since moved, which is what keeps a stale flag from going dark for ever. UseTls = target.UseTls, + + // Whether to type the plaintext MSSP-REQUEST, from what this address did with it + // before. See MsspRoutes for both halves of that memory. + PlaintextMssp = MsspRoutes.Ask(target.MsspRoute), }, budget.Token); @@ -498,6 +502,22 @@ await targets.RecordAttemptAsync( { await targets.RecordTransportAsync(target.Id, transport, cancellationToken); } + + // The same shape for the MSSP route: written only when this probe taught something new. + // MsspRoutes.Learn already returns the row unchanged for a dial that failed. + if (MsspRoutes.Learn(target.MsspRoute, result) is var route && route != target.MsspRoute) + { + await targets.RecordMsspRouteAsync(target.Id, route, now, cancellationToken); + + if (route is MsspRoute.Plaintext or MsspRoute.None) + { + logger?.LogInformation( + "{Host}:{Port} {Answer} the plaintext MSSP-REQUEST", + target.Host, + target.Port, + route is MsspRoute.Plaintext ? "answered" : "did not answer"); + } + } } /// diff --git a/src/MUI.Crawler/Crawl/FieldObservations.cs b/src/MUI.Crawler/Crawl/FieldObservations.cs index b5abecd2..5dd8938f 100644 --- a/src/MUI.Crawler/Crawl/FieldObservations.cs +++ b/src/MUI.Crawler/Crawl/FieldObservations.cs @@ -181,7 +181,10 @@ private static IEnumerable Parsed( /// (ProbeOptions.MsspSettleGrace; found flapping capability.mssp.measured true/false /// in production for DIKU-family games that answer MSSP cleanly on every direct probe). Servers /// that only respond to IAC DO MSSP rather than advertising it themselves will appear as - /// until TNC gains a client-side request. + /// . A report that arrived by the plaintext + /// MSSP-REQUEST instead is , so it writes no negative + /// either: the game handed us MSSP, and publishing "no MSSP" beside its report would be false, + /// while it never offered option 70, so no true is written for the handshake. /// is not that case and is recorded as present: the /// server offered, we just chose not to hold the reply (§6.4). A protocol the library named that /// isn't in is still recorded — the registry isn't a gate on diff --git a/src/MUI.Crawler/Persistence/NpgsqlCrawlTargetRepository.cs b/src/MUI.Crawler/Persistence/NpgsqlCrawlTargetRepository.cs index 1723e5c3..2b4e4955 100644 --- a/src/MUI.Crawler/Persistence/NpgsqlCrawlTargetRepository.cs +++ b/src/MUI.Crawler/Persistence/NpgsqlCrawlTargetRepository.cs @@ -42,7 +42,7 @@ public sealed class NpgsqlCrawlTargetRepository(NpgsqlDataSource source) : ICraw crawl_delay AS CrawlDelay, first_seen_at AS FirstSeenAt, last_probed_at AS LastProbedAt, discovered_from_game_id AS DiscoveredFromGameId, depth AS Depth, is_operator_seed AS IsOperatorSeed, submitted_at AS SubmittedAt, - discovered_via AS DiscoveredVia, + discovered_via AS DiscoveredVia, mssp_route AS MsspRoute, (SELECT f.value FROM game_field f WHERE f.game_id = crawl_target.game_id @@ -189,6 +189,21 @@ await connection.ExecuteAsync(new CommandDefinition( cancellationToken: ct)); } + public async Task RecordMsspRouteAsync(Guid id, MsspRoute? route, DateTimeOffset at, CancellationToken ct) + { + await using var connection = await source.OpenConnectionAsync(ct); + + await connection.ExecuteAsync(new CommandDefinition( + """ + UPDATE crawl_target + SET mssp_route = @route, + mssp_route_at = CASE WHEN @route::text IS NULL THEN NULL ELSE @at END + WHERE id = @id + """, + new { id, route = MsspRouteSpelling.ToDb(route), at = at.ToUniversalTime() }, + cancellationToken: ct)); + } + /// /// Attaches the game this address turned out to be. /// @@ -244,6 +259,9 @@ private sealed class Row public string? MsspCharset { get; init; } + /// Text, not the enum, for the reason is. + public string? MsspRoute { get; init; } + /// /// Nullable because the subquery has no row to read for a target not yet bound to a game, /// and null there means the same as true: an address that has proved nothing yet is asked @@ -272,6 +290,35 @@ private sealed class Row Charset = Charset, MsspCharset = MsspCharset, AwaitingCorroboration = AwaitingCorroboration ?? true, + MsspRoute = MsspRouteSpelling.From(MsspRoute), }; } } + +/// +/// to and from the spellings +/// crawl_target_mssp_route_vocabulary allows. +/// +public static class MsspRouteSpelling +{ + public static string? ToDb(MsspRoute? route) => route switch + { + null => null, + MUI.Discovery.MsspRoute.Telnet => "telnet", + MUI.Discovery.MsspRoute.Plaintext => "plaintext", + MUI.Discovery.MsspRoute.None => "none", + _ => throw new ArgumentOutOfRangeException(nameof(route), route, "No spelling for this route."), + }; + + /// + /// An unknown spelling reads as unknown, which costs one trial dial rather than a crawl cycle + /// that throws on every row. + /// + public static MsspRoute? From(string? value) => value switch + { + "telnet" => MUI.Discovery.MsspRoute.Telnet, + "plaintext" => MUI.Discovery.MsspRoute.Plaintext, + "none" => MUI.Discovery.MsspRoute.None, + _ => null, + }; +} diff --git a/src/MUI.Discovery/Scheduling/CrawlTarget.cs b/src/MUI.Discovery/Scheduling/CrawlTarget.cs index 9eab3280..0ffeb4c3 100644 --- a/src/MUI.Discovery/Scheduling/CrawlTarget.cs +++ b/src/MUI.Discovery/Scheduling/CrawlTarget.cs @@ -129,6 +129,17 @@ public sealed record CrawlTarget /// /// public bool AwaitingCorroboration { get; init; } = true; + + /// + /// Which way this address hands over MSSP, as far as the crawl loop has learnt, or null when it + /// has learnt nothing yet (migration 0042). + /// + /// + /// The address's own column, like and for the same reason: a memory of what + /// a conversation with this listener established. owns both what it + /// asks for and how it changes. + /// + public MsspRoute? MsspRoute { get; init; } } /// @@ -172,5 +183,15 @@ Task RecordAttemptAsync( /// Task RecordTransportAsync(Guid id, bool useTls, CancellationToken ct); + /// + /// Records which way this address hands over MSSP, so the next dial knows whether to send the + /// plaintext MSSP-REQUEST. Null forgets it, which earns the address one more trial. + /// + /// + /// Separate from for the reason + /// is: most attempts have nothing new to say about it, and the caller writes only on a change. + /// + Task RecordMsspRouteAsync(Guid id, MsspRoute? route, DateTimeOffset at, CancellationToken ct); + Task AttachGameAsync(Guid id, Guid gameId, CancellationToken ct); } diff --git a/src/MUI.Discovery/Scheduling/MsspRoutes.cs b/src/MUI.Discovery/Scheduling/MsspRoutes.cs new file mode 100644 index 00000000..2356d64e --- /dev/null +++ b/src/MUI.Discovery/Scheduling/MsspRoutes.cs @@ -0,0 +1,93 @@ +using MUI.Crawl; + +namespace MUI.Discovery; + +/// +/// Which way an address hands over its MSSP report, as far as the crawl loop has learnt +/// (crawl_target.mssp_route, migration 0042). Null on a target means nothing is known yet. +/// +public enum MsspRoute +{ + /// A report has arrived over telnet option 70. The plaintext form is never sent. + Telnet, + + /// Answered the plaintext MSSP-REQUEST, so it is asked every session. + Plaintext, + + /// Sent MSSP-REQUEST and did not answer it. Never asked again. + None, +} + +/// +/// The two decisions exists for: what to ask this time, and what the answer +/// teaches. +/// +/// +/// Here rather than in the crawl loop so both are a pure function of a target and a +/// , testable without a socket or a database. +/// +public static class MsspRoutes +{ + /// What the probe is to do about MSSP-REQUEST at an address on this route. + /// + /// An unknown address gets one trial, on a dial of its own, and only once the probe has seen the + /// ordinary session come back without option 70 (). The probe + /// decides that last half, since only it has the session. + /// + public static PlaintextMsspAsk Ask(MsspRoute? route) => route switch + { + null => PlaintextMsspAsk.Trial, + MsspRoute.Plaintext => PlaintextMsspAsk.Ask, + _ => PlaintextMsspAsk.Never, + }; + + /// + /// The route this probe has taught, or when it taught nothing. + /// + /// + /// + /// Option 70 wins from any state, and once learnt is never unlearnt: the plaintext form is for + /// games that have not given us MSSP the proper way, and a game that has once is not asked it + /// again because one session's report went missing. Counted from the outcome rather than the + /// report, so a report too large to keep still counts: the game offered it, we declined it. + /// + /// + /// An unanswered request is final from the unknown state, which is the point. From + /// it returns the address to unknown instead, so the next crawl + /// gives it one more trial. A game that has answered before and misses once has more likely been + /// slow than changed its code, and losing its only MSSP route for ever on one slow night would be + /// our timing recorded as a fact about it (rule 5). A second miss, on the trial, settles it. + /// + /// + /// A failed dial teaches nothing, and neither does a session that never sent the line. + /// + /// + public static MsspRoute? Learn(MsspRoute? current, ProbeResult result) + { + ArgumentNullException.ThrowIfNull(result); + + if (result.Outcome is not ProbeOutcome.Answered) + { + return current; + } + + if (result.MsspOutcome is not MsspOutcome.NotOffered + && result.PlaintextMssp is not PlaintextMsspOutcome.Answered) + { + return MsspRoute.Telnet; + } + + if (current is MsspRoute.Telnet) + { + return current; + } + + return result.PlaintextMssp switch + { + PlaintextMsspOutcome.Answered => MsspRoute.Plaintext, + PlaintextMsspOutcome.Unanswered when current is MsspRoute.Plaintext => null, + PlaintextMsspOutcome.Unanswered => MsspRoute.None, + _ => current, + }; + } +} diff --git a/src/MUI.Probe/Program.cs b/src/MUI.Probe/Program.cs index dd36271e..079cce01 100644 --- a/src/MUI.Probe/Program.cs +++ b/src/MUI.Probe/Program.cs @@ -9,6 +9,14 @@ // catalogue to ask, and a caller that cannot answer keeps the probe asking. var listed = args.Contains("--listed", StringComparer.Ordinal); +// Whether to try the plaintext MSSP-REQUEST, as the crawl loop does for an address that has never +// reported over option 70: `--mssp-request` asks it on a second dial after the measurement +// (PlaintextMsspAsk.Trial), `--mssp-request=ask` inside the measurement, before WHO, as for an +// address known to answer. Off by default, because it is text at a stranger's login prompt. +var plaintextMssp = args.Contains("--mssp-request=ask", StringComparer.Ordinal) + ? PlaintextMsspAsk.Ask + : args.Contains("--mssp-request", StringComparer.Ordinal) ? PlaintextMsspAsk.Trial : PlaintextMsspAsk.Never; + // Flags are pulled out first so they can be written anywhere without displacing a positional. var positional = args.Where(a => !a.StartsWith("--", StringComparison.Ordinal)).ToArray(); @@ -35,6 +43,7 @@ Charset = charset, MsspCharset = msspCharset, AwaitingCorroboration = !listed, + PlaintextMssp = plaintextMssp, }); Console.WriteLine($"target {result.Host}:{result.Port}"); @@ -48,6 +57,11 @@ : $"transport none answered ({result.Transport.ToString().ToLowerInvariant()} attempted)"); Console.WriteLine($"elapsed {result.Elapsed.TotalSeconds:F1}s"); Console.WriteLine($"mssp {result.MsspOutcome} via {result.MsspTransport}"); + +if (plaintextMssp is not PlaintextMsspAsk.Never) +{ + Console.WriteLine($"mssp-request {result.PlaintextMssp}"); +} Console.WriteLine($"who {result.Who.Confidence}" + (result.Who.HasCount ? $" \u2192 {result.Who.Count} players" : result.Who.Attempted ? " \u2014 asked, unreadable" : " \u2014 never asked")); diff --git a/tests/MUI.Crawl.Tests/Telnet/PlaintextMsspTests.cs b/tests/MUI.Crawl.Tests/Telnet/PlaintextMsspTests.cs new file mode 100644 index 00000000..7f5c5de8 --- /dev/null +++ b/tests/MUI.Crawl.Tests/Telnet/PlaintextMsspTests.cs @@ -0,0 +1,339 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using MUI.Crawl; +using TelnetNegotiationCore.Builders; +using TelnetNegotiationCore.Interpreters; +using TelnetNegotiationCore.Models; +using TelnetNegotiationCore.Protocols; + +namespace MUI.Crawl.Tests; + +/// +/// The plaintext MSSP-REQUEST, end to end against servers in this process. +/// +/// +/// TelnetNegotiationCore owns the exchange itself and tests it there. What is pinned here is ours: +/// the line goes out only when the target says so, never to a game negotiating option 70, never +/// inside the session that measures a game for the first time, and what comes back is recorded as +/// MSSP without being recorded as a negotiation. +/// +public class PlaintextMsspTests +{ + private const string Request = "MSSP-REQUEST"; + + private static ProbeOptions Fast() => new() + { + QuietPeriod = TimeSpan.FromMilliseconds(120), + SilenceGrace = TimeSpan.FromMilliseconds(300), + MaxPhase = TimeSpan.FromSeconds(3), + BannerPatience = TimeSpan.FromMilliseconds(300), + WhoGrace = TimeSpan.FromMilliseconds(700), + PollInterval = TimeSpan.FromMilliseconds(15), + Timeout = TimeSpan.FromSeconds(20), + MsspSettleGrace = TimeSpan.FromMilliseconds(400), + PromptHold = TimeSpan.FromMilliseconds(120), + PlaintextMsspGrace = TimeSpan.FromMilliseconds(800), + }; + + [Test] + public async Task NothingIsSentUnlessTheTargetSaysSo() + { + await using var game = new OldRealms { AnswersRequest = true }; + + var result = await new TelnetProbe(Fast()).ProbeAsync(game.Target); + + await Assert.That(result.PlaintextMssp).IsEqualTo(PlaintextMsspOutcome.NotAsked); + await Assert.That(game.Sessions).Count().IsEqualTo(1); + await Assert.That(game.Sessions.SelectMany(lines => lines)).DoesNotContain(Request); + } + + [Test] + public async Task ATrialAsksOnADialOfItsOwnAndKeepsTheReport() + { + await using var game = new OldRealms { AnswersRequest = true }; + + var result = await new TelnetProbe(Fast()).ProbeAsync( + game.Target with { PlaintextMssp = PlaintextMsspAsk.Trial }); + + await Assert.That(result.PlaintextMssp).IsEqualTo(PlaintextMsspOutcome.Answered); + await Assert.That(result.MsspOutcome).IsEqualTo(MsspOutcome.Received); + await Assert.That(result.MsspTransport).IsEqualTo(MsspTransport.PlaintextRequest); + await Assert.That(MsspReport.Last(result.Mssp, "NAME")).IsEqualTo("Old Realms"); + await Assert.That(MsspReport.Last(result.Mssp, "PLAYERS")).IsEqualTo("4"); + + // A line of text is not a telnet negotiation, and the handshake field is a measurement of + // negotiations. Noting MSSP here would publish option 70 for a game that never offered it. + await Assert.That(result.OfferedOptions).DoesNotContain("MSSP"); + + // The measurement saw none of it, and the trial asked nothing else. + await Assert.That(game.Sessions).Count().IsEqualTo(2); + await Assert.That(game.Sessions[0]).DoesNotContain(Request); + await Assert.That(game.Sessions[0]).Contains("WHO"); + await Assert.That(game.Sessions[1]).IsEquivalentTo(new[] { Request }); + await Assert.That(result.Who.Count).IsEqualTo(3); + } + + /// + /// The reason the trial has a dial of its own. + /// + /// + /// The shape playdecay.com:3003 was measured in: an unknown name is answered with a + /// password prompt, and the next line is read as the password and ends the session. Had the + /// request gone out in the measuring session, WHO would have been typed as a password and + /// the hour recorded as uncountable — our experiment published as a fact about the game. + /// + [Test] + public async Task AGameThatReadsTheRequestAsANameHasItsMeasurementLeftAlone() + { + await using var game = new OldRealms { AsksForAPasswordAfterAName = true }; + + var result = await new TelnetProbe(Fast()).ProbeAsync( + game.Target with { PlaintextMssp = PlaintextMsspAsk.Trial }); + + await Assert.That(result.PlaintextMssp).IsEqualTo(PlaintextMsspOutcome.Unanswered); + await Assert.That(result.MsspOutcome).IsEqualTo(MsspOutcome.NotOffered); + await Assert.That(result.MsspTransport).IsEqualTo(MsspTransport.None); + await Assert.That(result.Who.Count).IsEqualTo(3); + await Assert.That(game.Sessions[1]).IsEquivalentTo(new[] { Request }); + } + + [Test] + public async Task AGameThatHangsUpOnTheRequestHasAnsweredIt() + { + await using var game = new OldRealms { HangsUpOnAName = true }; + + var result = await new TelnetProbe(Fast()).ProbeAsync( + game.Target with { PlaintextMssp = PlaintextMsspAsk.Trial }); + + await Assert.That(result.Outcome).IsEqualTo(ProbeOutcome.Answered); + await Assert.That(result.PlaintextMssp).IsEqualTo(PlaintextMsspOutcome.Unanswered); + await Assert.That(result.Who.Count).IsEqualTo(3); + } + + [Test] + public async Task AGameNegotiatingOption70IsNeverSentIt() + { + foreach (var ask in new[] { PlaintextMsspAsk.Trial, PlaintextMsspAsk.Ask }) + { + await using var game = new OldRealms { AnswersRequest = true, OffersOption70 = true }; + + var result = await new TelnetProbe(Fast()).ProbeAsync(game.Target with { PlaintextMssp = ask }); + + await Assert.That(result.MsspTransport).IsEqualTo(MsspTransport.TelnetOption70); + await Assert.That(result.PlaintextMssp).IsEqualTo(PlaintextMsspOutcome.NotAsked); + await Assert.That(game.Sessions).Count().IsEqualTo(1); + await Assert.That(game.Sessions[0]).DoesNotContain(Request); + } + } + + [Test] + public async Task AKnownAnswererIsAskedBeforeWhoAndSparedTheWho() + { + await using var game = new OldRealms { AnswersRequest = true }; + + var result = await new TelnetProbe(Fast()).ProbeAsync( + game.Target with { PlaintextMssp = PlaintextMsspAsk.Ask }); + + await Assert.That(result.PlaintextMssp).IsEqualTo(PlaintextMsspOutcome.Answered); + await Assert.That(result.MsspTransport).IsEqualTo(MsspTransport.PlaintextRequest); + await Assert.That(MsspReport.Last(result.Mssp, "PLAYERS")).IsEqualTo("4"); + await Assert.That(result.OfferedOptions).DoesNotContain("MSSP"); + + // One session: the request first, and WHO never, because the report stated a count. + await Assert.That(game.Sessions).Count().IsEqualTo(1); + await Assert.That(game.Sessions[0].First()).IsEqualTo(Request); + await Assert.That(game.Sessions[0]).DoesNotContain("WHO"); + + // The reply is protocol, not screen, and the prompt it re-printed is our doing. + await Assert.That(result.Banner).DoesNotContain("MSSP"); + await Assert.That(result.Banner).Contains("Welcome to Old Realms"); + } + + [Test] + public async Task AKnownAnswererThatFallsSilentIsRecordedAsSuch() + { + await using var game = new OldRealms(); + + var result = await new TelnetProbe(Fast()).ProbeAsync( + game.Target with { PlaintextMssp = PlaintextMsspAsk.Ask }); + + await Assert.That(result.PlaintextMssp).IsEqualTo(PlaintextMsspOutcome.Unanswered); + await Assert.That(result.MsspOutcome).IsEqualTo(MsspOutcome.NotOffered); + await Assert.That(game.Sessions).Count().IsEqualTo(1); + } + + /// + /// A connect screen at a name prompt, served to as many connections as arrive. + /// + /// + /// A TelnetNegotiationCore server for the framing, with the plaintext reply written by hand: + /// the library's own server half needs MSSPProtocol registered, which would offer option + /// 70 as well, and the game under test here is one that does not. The reply is written the way + /// SMAUG's send_mssp_data writes it. + /// + private sealed class OldRealms : IAsyncDisposable + { + private const string Prompt = "By what name do you wish to be known? "; + + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stopping = new(); + private readonly List> _sessions = []; + private readonly Task _serving; + + public OldRealms() + { + _listener.Start(); + _serving = AcceptAsync(); + } + + public bool AnswersRequest { get; init; } + + public bool OffersOption70 { get; init; } + + /// Whether an unknown name is followed by a password prompt that ends the session. + public bool AsksForAPasswordAfterAName { get; init; } + + public bool HangsUpOnAName { get; init; } + + public ProbeTarget Target => new( + IPAddress.Loopback.ToString(), + ((IPEndPoint)_listener.LocalEndpoint).Port); + + public IReadOnlyList> Sessions + { + get + { + lock (_sessions) + { + return [.. _sessions.Select(lines => (IReadOnlyList)[.. lines])]; + } + } + } + + private async Task AcceptAsync() + { + var serving = new List(); + + try + { + while (!_stopping.IsCancellationRequested) + { + var client = await _listener.AcceptTcpClientAsync(_stopping.Token); + var lines = new List(); + + lock (_sessions) + { + _sessions.Add(lines); + } + + serving.Add(ServeAsync(client, lines)); + } + } + catch (Exception error) when (error is OperationCanceledException or SocketException) + { + } + + await Task.WhenAll(serving); + } + + private async Task ServeAsync(TcpClient client, List lines) + { + using var _ = client; + var awaitingPassword = false; + + try + { + var builder = new TelnetInterpreterBuilder() + .UseMode(TelnetInterpreter.TelnetMode.Server) + .UseLogger(NullLogger.Instance) + .OnSubmit(async (bytes, encoding, telnet) => + { + var line = encoding.GetString(bytes).Trim(); + + lock (_sessions) + { + lines.Add(line); + } + + if (awaitingPassword) + { + await WriteAsync(telnet, "Wrong password.\r\n"); + client.Client.Shutdown(SocketShutdown.Both); + return; + } + + switch (line.ToUpperInvariant()) + { + case "": + await WriteAsync(telnet, Prompt); + return; + + case Request when AnswersRequest: + await WriteAsync( + telnet, + "\r\nMSSP-REPLY-START\r\nNAME\tOld Realms\r\nPLAYERS\t4\r\n" + + "UPTIME\t1700000000\r\nMSSP-REPLY-END\r\n" + Prompt); + return; + + case "WHO": + await WriteAsync( + telnet, + "Player Name On For Idle\r\n" + + "3 Players logged in, 22 record, no maximum.\r\n" + Prompt); + return; + + case "INFO" or "VERSION": + await WriteAsync(telnet, Prompt); + return; + } + + if (HangsUpOnAName) + { + client.Client.Shutdown(SocketShutdown.Both); + } + else if (AsksForAPasswordAfterAName) + { + awaitingPassword = true; + await WriteAsync(telnet, "Password: "); + } + else + { + await WriteAsync(telnet, "Illegal name, try another.\r\n" + Prompt); + } + }); + + if (OffersOption70) + { + var mssp = new MSSPProtocol(); + mssp.SetMSSPConfig(() => new MSSPConfig { Name = "Old Realms", Players = 4 }); + builder = builder.AddPlugin(mssp); + } + + var built = await builder.BuildAndStartAsync(client, _stopping.Token); + await using var telnet = built.Interpreter; + + await WriteAsync(telnet, "Welcome to Old Realms\r\nA SMAUG-shaped place.\r\n" + Prompt); + await built.ReadTask; + } + catch (Exception error) when (error is OperationCanceledException + or IOException + or SocketException + or ObjectDisposedException) + { + } + } + + private static ValueTask WriteAsync(TelnetInterpreter telnet, string text) => + telnet.WriteToNetworkAsync(Encoding.Latin1.GetBytes(text)); + + public async ValueTask DisposeAsync() + { + await _stopping.CancelAsync(); + _listener.Stop(); + await _serving; + _stopping.Dispose(); + } + } +} diff --git a/tests/MUI.Crawl.Tests/Telnet/ProbeRestraintTests.cs b/tests/MUI.Crawl.Tests/Telnet/ProbeRestraintTests.cs index c9f467f2..2f38df31 100644 --- a/tests/MUI.Crawl.Tests/Telnet/ProbeRestraintTests.cs +++ b/tests/MUI.Crawl.Tests/Telnet/ProbeRestraintTests.cs @@ -40,11 +40,14 @@ public async Task TheOnlyThingsItAsksForAreThePreLoginReadOnlyCommands() } [Test] - public async Task PlaintextMsspRequestIsNeverTypedAtALoginScreen() + public async Task PlaintextMsspRequestIsNotACommandAndIsOffUnlessTheCrawlLoopAsks() { - // The plaintext MSSP-REQUEST form is text at a login screen, and most games tried read it as - // a character name. It belongs in TelnetNegotiationCore, not here. + // The plaintext MSSP-REQUEST form is text at a login screen, and eight of twenty games tried + // read it as a character name. TelnetNegotiationCore sends it, not this probe's command list, + // and only for a target the crawl loop has decided may be asked (PlaintextMsspTests). await Assert.That(TelnetProbe.PermittedCommands).DoesNotContain("MSSP-REQUEST"); + await Assert.That(new ProbeTarget("mud.example.org", 4000).PlaintextMssp) + .IsEqualTo(PlaintextMsspAsk.Never); } [Test] diff --git a/tests/MUI.Crawler.Tests/Crawl/MsspRoutePostgresTests.cs b/tests/MUI.Crawler.Tests/Crawl/MsspRoutePostgresTests.cs new file mode 100644 index 00000000..b65bdc46 --- /dev/null +++ b/tests/MUI.Crawler.Tests/Crawl/MsspRoutePostgresTests.cs @@ -0,0 +1,170 @@ +using Dapper; + +using MUI.Crawl; +using MUI.Crawler.Tests.Support; +using MUI.Discovery; + +namespace MUI.Crawler.Tests; + +/// +/// What the crawl loop remembers about the plaintext MSSP-REQUEST, and what it asks next time. +/// +/// +/// The probe decides how to send the line and reads what came back; these are about the memory +/// between dials (crawl_target.mssp_route), which is the whole of the "not again" in "try it, +/// and do not try it again on a game that does not answer". +/// +public class MsspRoutePostgresTests +{ + private const string Host = "realms.example.org"; + private const int Port = 4000; + + [Test] + public async Task AnAddressNobodyKnowsAnythingAboutIsGivenOneTrial() + { + await using var database = await PostgresFixture.MigratedAsync(); + var source = database.DataSource; + + await CrawlCycles.SeedAsync(source, new CrawlSeed(Host, Port)); + + var probe = new ScriptedProbe(target => Probes.Answered(host: target.Host, port: target.Port)); + await CrawlCycles.Build(source, probe, TimeProvider.System).RunAsync(); + + await Assert.That(probe.Asked.Single().PlaintextMssp).IsEqualTo(PlaintextMsspAsk.Trial); + } + + [Test] + public async Task AGameThatAnsweredIsAskedInTheSessionFromThenOn() + { + await using var database = await PostgresFixture.MigratedAsync(); + var source = database.DataSource; + + await CrawlCycles.SeedAsync(source, new CrawlSeed(Host, Port)); + + var trial = new ScriptedProbe(target => AnsweredInPlaintext(target)); + await CrawlCycles.Build(source, trial, TimeProvider.System).RunAsync(); + + await using var connection = await source.OpenConnectionAsync(); + + await Assert.That(await RouteAsync(connection)).IsEqualTo("plaintext"); + + // The report it sent is stored the way an option-70 report is: it is the same report. + await Assert.That(await connection.ExecuteScalarAsync( + "SELECT value FROM game_field WHERE field = 'NAME' AND source = 'mssp'")) + .IsEqualTo("Old Realms"); + + await DueAgainAsync(connection); + + var next = new ScriptedProbe(target => AnsweredInPlaintext(target)); + await CrawlCycles.Build(source, next, TimeProvider.System).RunAsync(); + + await Assert.That(next.Asked.Single().PlaintextMssp).IsEqualTo(PlaintextMsspAsk.Ask); + } + + [Test] + public async Task AGameThatDidNotAnswerIsNeverAskedAgain() + { + await using var database = await PostgresFixture.MigratedAsync(); + var source = database.DataSource; + + await CrawlCycles.SeedAsync(source, new CrawlSeed(Host, Port)); + + var trial = new ScriptedProbe(target => Probes.Answered(host: target.Host, port: target.Port) with + { + PlaintextMssp = PlaintextMsspOutcome.Unanswered, + }); + await CrawlCycles.Build(source, trial, TimeProvider.System).RunAsync(); + + await using var connection = await source.OpenConnectionAsync(); + + await Assert.That(await RouteAsync(connection)).IsEqualTo("none"); + await Assert.That(await connection.ExecuteScalarAsync( + "SELECT mssp_route_at IS NOT NULL FROM crawl_target")).IsTrue(); + + await DueAgainAsync(connection); + + var next = new ScriptedProbe(target => Probes.Answered(host: target.Host, port: target.Port)); + await CrawlCycles.Build(source, next, TimeProvider.System).RunAsync(); + + await Assert.That(next.Asked.Single().PlaintextMssp).IsEqualTo(PlaintextMsspAsk.Never); + await Assert.That(await RouteAsync(connection)).IsEqualTo("none"); + } + + [Test] + public async Task AGameReportingOverOption70IsNeverSentTheRequest() + { + await using var database = await PostgresFixture.MigratedAsync(); + var source = database.DataSource; + + await CrawlCycles.SeedAsync(source, new CrawlSeed(Host, Port)); + + var first = new ScriptedProbe(target => Probes.Answered( + host: target.Host, + port: target.Port, + mssp: Probes.Mssp(("NAME", "Old Realms")))); + await CrawlCycles.Build(source, first, TimeProvider.System).RunAsync(); + + await using var connection = await source.OpenConnectionAsync(); + + await Assert.That(await RouteAsync(connection)).IsEqualTo("telnet"); + + await DueAgainAsync(connection); + + // A session where the report went missing does not reopen the question. + var next = new ScriptedProbe(target => Probes.Answered(host: target.Host, port: target.Port)); + await CrawlCycles.Build(source, next, TimeProvider.System).RunAsync(); + + await Assert.That(next.Asked.Single().PlaintextMssp).IsEqualTo(PlaintextMsspAsk.Never); + await Assert.That(await RouteAsync(connection)).IsEqualTo("telnet"); + } + + [Test] + public async Task AFailedDialTeachesNothing() + { + await using var database = await PostgresFixture.MigratedAsync(); + var source = database.DataSource; + + await CrawlCycles.SeedAsync(source, new CrawlSeed(Host, Port)); + + var probe = new ScriptedProbe(target => Probes.Failed(host: target.Host, port: target.Port)); + await CrawlCycles.Build(source, probe, TimeProvider.System).RunAsync(); + + await using var connection = await source.OpenConnectionAsync(); + + await Assert.That(await RouteAsync(connection)).IsNull(); + } + + [Test] + public async Task TheSchemaRefusesARouteNobodyDated() + { + await using var database = await PostgresFixture.MigratedAsync(); + var source = database.DataSource; + + await CrawlCycles.SeedAsync(source, new CrawlSeed(Host, Port)); + + await using var connection = await source.OpenConnectionAsync(); + + await Assert.That(async () => await connection.ExecuteAsync( + "UPDATE crawl_target SET mssp_route = 'none'")) + .Throws(); + await Assert.That(async () => await connection.ExecuteAsync( + "UPDATE crawl_target SET mssp_route = 'gopher', mssp_route_at = now()")) + .Throws(); + } + + private static ProbeResult AnsweredInPlaintext(ProbeTarget target) => + Probes.Answered( + host: target.Host, + port: target.Port, + mssp: Probes.Mssp(("NAME", "Old Realms"), ("PLAYERS", "4"))) with + { + MsspTransport = MsspTransport.PlaintextRequest, + PlaintextMssp = PlaintextMsspOutcome.Answered, + }; + + private static Task RouteAsync(Npgsql.NpgsqlConnection connection) => + connection.ExecuteScalarAsync("SELECT mssp_route FROM crawl_target"); + + private static Task DueAgainAsync(Npgsql.NpgsqlConnection connection) => + connection.ExecuteAsync("UPDATE crawl_target SET next_probe_at = now() - interval '1 hour'"); +} diff --git a/tests/MUI.Crawler.Tests/Support/FakeStores.cs b/tests/MUI.Crawler.Tests/Support/FakeStores.cs index f0c775f5..ec00405e 100644 --- a/tests/MUI.Crawler.Tests/Support/FakeStores.cs +++ b/tests/MUI.Crawler.Tests/Support/FakeStores.cs @@ -63,6 +63,9 @@ public Task RecordAttemptAsync( public Task RecordTransportAsync(Guid id, bool useTls, CancellationToken ct) => Task.CompletedTask; + public Task RecordMsspRouteAsync(Guid id, MsspRoute? route, DateTimeOffset at, CancellationToken ct) => + Task.CompletedTask; + public Task AttachGameAsync(Guid id, Guid gameId, CancellationToken ct) => Task.CompletedTask; } diff --git a/tests/MUI.Discovery.Tests/Scheduling/MsspRoutesTests.cs b/tests/MUI.Discovery.Tests/Scheduling/MsspRoutesTests.cs new file mode 100644 index 00000000..7925d315 --- /dev/null +++ b/tests/MUI.Discovery.Tests/Scheduling/MsspRoutesTests.cs @@ -0,0 +1,91 @@ +using MUI.Crawl; +using MUI.Discovery.Tests.Support; + +namespace MUI.Discovery.Tests; + +/// +/// When the plaintext MSSP-REQUEST is sent, and what each answer teaches. +/// +public class MsspRoutesTests +{ + private static ProbeResult Plaintext(PlaintextMsspOutcome outcome) => + ProbeResults.Answered() with { PlaintextMssp = outcome }; + + private static ProbeResult PlaintextReport() => + ProbeResults.Answered(mssp: ProbeResults.Mssp(("NAME", "Old Realms"))) with + { + MsspTransport = MsspTransport.PlaintextRequest, + PlaintextMssp = PlaintextMsspOutcome.Answered, + }; + + [Test] + public async Task OnlyAnUnknownAddressIsGivenATrialAndOnlyAKnownAnswererIsAsked() + { + await Assert.That(MsspRoutes.Ask(null)).IsEqualTo(PlaintextMsspAsk.Trial); + await Assert.That(MsspRoutes.Ask(MsspRoute.Plaintext)).IsEqualTo(PlaintextMsspAsk.Ask); + await Assert.That(MsspRoutes.Ask(MsspRoute.Telnet)).IsEqualTo(PlaintextMsspAsk.Never); + await Assert.That(MsspRoutes.Ask(MsspRoute.None)).IsEqualTo(PlaintextMsspAsk.Never); + } + + [Test] + public async Task AnOption70ReportSettlesItFromEveryState() + { + var report = ProbeResults.Answered(mssp: ProbeResults.Mssp(("NAME", "Old Realms"))); + + await Assert.That(MsspRoutes.Learn(null, report)).IsEqualTo(MsspRoute.Telnet); + await Assert.That(MsspRoutes.Learn(MsspRoute.None, report)).IsEqualTo(MsspRoute.Telnet); + await Assert.That(MsspRoutes.Learn(MsspRoute.Plaintext, report)).IsEqualTo(MsspRoute.Telnet); + } + + [Test] + public async Task AReportTooLargeToKeepIsStillAReportOverOption70() + { + var dropped = ProbeResults.Answered() with { MsspOutcome = MsspOutcome.RejectedTooLarge }; + + await Assert.That(MsspRoutes.Learn(null, dropped)).IsEqualTo(MsspRoute.Telnet); + } + + [Test] + public async Task OnceOption70IsKnownASessionWithoutItReopensNothing() + { + await Assert.That(MsspRoutes.Learn(MsspRoute.Telnet, ProbeResults.Answered())).IsEqualTo(MsspRoute.Telnet); + } + + [Test] + public async Task AnAnswerMakesItAPlaintextAddress() + { + await Assert.That(MsspRoutes.Learn(null, PlaintextReport())).IsEqualTo(MsspRoute.Plaintext); + await Assert.That(MsspRoutes.Learn(MsspRoute.Plaintext, PlaintextReport())).IsEqualTo(MsspRoute.Plaintext); + } + + [Test] + public async Task AnOversizedPlaintextAnswerIsStillAnAnswer() + { + var dropped = Plaintext(PlaintextMsspOutcome.Answered) with { MsspOutcome = MsspOutcome.RejectedTooLarge }; + + await Assert.That(MsspRoutes.Learn(null, dropped)).IsEqualTo(MsspRoute.Plaintext); + } + + [Test] + public async Task NoAnswerToTheTrialIsFinal() + { + await Assert.That(MsspRoutes.Learn(null, Plaintext(PlaintextMsspOutcome.Unanswered))) + .IsEqualTo(MsspRoute.None); + } + + [Test] + public async Task AFormerAnswererThatMissesOnceGetsOneMoreTrialRatherThanBeingDropped() + { + await Assert.That(MsspRoutes.Learn(MsspRoute.Plaintext, Plaintext(PlaintextMsspOutcome.Unanswered))) + .IsNull(); + } + + [Test] + public async Task NotAskingAndFailingToDialTeachNothing() + { + await Assert.That(MsspRoutes.Learn(null, ProbeResults.Answered())).IsNull(); + await Assert.That(MsspRoutes.Learn(MsspRoute.Plaintext, ProbeResults.Answered())).IsEqualTo(MsspRoute.Plaintext); + await Assert.That(MsspRoutes.Learn(MsspRoute.None, ProbeResults.Failed())).IsEqualTo(MsspRoute.None); + await Assert.That(MsspRoutes.Learn(null, ProbeResults.Failed())).IsNull(); + } +} diff --git a/tests/MUI.Discovery.Tests/Support/InMemoryCrawlTargetRepository.cs b/tests/MUI.Discovery.Tests/Support/InMemoryCrawlTargetRepository.cs index c7701d38..dd9557ff 100644 --- a/tests/MUI.Discovery.Tests/Support/InMemoryCrawlTargetRepository.cs +++ b/tests/MUI.Discovery.Tests/Support/InMemoryCrawlTargetRepository.cs @@ -71,6 +71,13 @@ public Task RecordTransportAsync(Guid id, bool useTls, CancellationToken ct) return Task.CompletedTask; } + public Task RecordMsspRouteAsync(Guid id, MsspRoute? route, DateTimeOffset at, CancellationToken ct) + { + Replace(id, target => target with { MsspRoute = route }); + + return Task.CompletedTask; + } + public Task AttachGameAsync(Guid id, Guid gameId, CancellationToken ct) { Replace(id, target => target.GameId is null ? target with { GameId = gameId } : target); diff --git a/tests/MUI.Web.Tests/StoredCrawlerPulseTests.cs b/tests/MUI.Web.Tests/StoredCrawlerPulseTests.cs index ad5f7bd9..c3382bba 100644 --- a/tests/MUI.Web.Tests/StoredCrawlerPulseTests.cs +++ b/tests/MUI.Web.Tests/StoredCrawlerPulseTests.cs @@ -180,6 +180,9 @@ public Task RecordAttemptAsync( public Task RecordTransportAsync(Guid id, bool useTls, CancellationToken ct) => throw new NotSupportedException(); + public Task RecordMsspRouteAsync(Guid id, MsspRoute? route, DateTimeOffset at, CancellationToken ct) => + throw new NotSupportedException(); + public Task AttachGameAsync(Guid id, Guid gameId, CancellationToken ct) => throw new NotSupportedException(); }