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
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions docs/codebase-survey-2026-07-30.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
59 changes: 59 additions & 0 deletions migrations/0042_mssp_route.sql
Original file line number Diff line number Diff line change
@@ -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'));
17 changes: 12 additions & 5 deletions src/MUI.Crawl/Telnet/ProbeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,18 @@ public sealed record ProbeOptions
/// </remarks>
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.
/// <summary>
/// How long a plaintext <c>MSSP-REQUEST</c> waits for <c>MSSP-REPLY-END</c> before the game is
/// taken not to answer it.
/// </summary>
/// <remarks>
/// Handed to TelnetNegotiationCore's <c>MSSPPlaintextProtocol.WithReplyTimeout</c>. Ten seconds is
/// the library's own default and Grapevine's crawler's window. Every reply measured
/// (<c>docs/codebase-survey-2026-07-30.md</c>) 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
/// <see cref="ProbeTarget.PlaintextMssp"/> says to ask at all.
/// </remarks>
public TimeSpan PlaintextMsspGrace { get; init; } = TimeSpan.FromSeconds(10);

/// <summary>
/// Ceiling on a single subnegotiation payload, handed to TelnetNegotiationCore's
Expand Down
44 changes: 40 additions & 4 deletions src/MUI.Crawl/Telnet/ProbeResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,17 @@ public IReadOnlyList<string> MsspValues(string variable) =>
/// <summary>Which transport produced <see cref="Mssp"/>. Part of the value's provenance.</summary>
public MsspTransport MsspTransport { get; init; } = MsspTransport.None;

/// <summary>
/// What came of the plaintext <c>MSSP-REQUEST</c>, when <see cref="ProbeTarget.PlaintextMssp"/>
/// had it sent.
/// </summary>
/// <remarks>
/// Kept apart from <see cref="MsspOutcome"/>, which an unanswered request never changes: a game
/// that ignored the plaintext line has the same <see cref="MsspOutcome.NotOffered"/> it had
/// before it was asked. Read by the crawl loop, to decide whether to ask again.
/// </remarks>
public PlaintextMsspOutcome PlaintextMssp { get; init; } = PlaintextMsspOutcome.NotAsked;

public FailureDetail? Failure { get; init; }

/// <summary>
Expand Down Expand Up @@ -168,7 +179,10 @@ public IReadOnlyList<string> MsspValues(string variable) =>
/// </remarks>
public enum MsspOutcome
{
/// <summary>The server never offered MSSP and did not answer the plaintext request.</summary>
/// <summary>
/// No report arrived: the server never offered MSSP and, if it was sent the plaintext request,
/// did not answer that either.
/// </summary>
NotOffered,

/// <summary>A report arrived and was parsed. It may still be empty, which is the server's answer.</summary>
Expand All @@ -194,13 +208,35 @@ public enum MsspTransport

/// <summary>The plaintext <c>MSSP-REQUEST</c> reply, delimited by START/END markers.</summary>
/// <remarks>
/// 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 <c>MSSPPlaintextProtocol</c>; this repository only decides
/// when to ask (<see cref="ProbeTarget.PlaintextMssp"/>).
/// </remarks>
PlaintextRequest,
}

/// <summary>What came of a plaintext <c>MSSP-REQUEST</c>.</summary>
public enum PlaintextMsspOutcome
{
/// <summary>
/// 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.
/// </summary>
NotAsked,

/// <summary>
/// The game answered with an <c>MSSP-REPLY-START</c> to <c>MSSP-REPLY-END</c> block, including
/// one too large to keep, which is still an answer.
/// </summary>
Answered,

/// <summary>
/// The line went out and no reply came back before <see cref="ProbeOptions.PlaintextMsspGrace"/>
/// ran out or the game hung up. A measurement of the game, and the one that stops it being asked
/// again.
/// </summary>
Unanswered,
}

public enum ProbeOutcome
{
Answered,
Expand Down
54 changes: 54 additions & 0 deletions src/MUI.Crawl/Telnet/Probing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,63 @@ public sealed record ProbeTarget(string Host, int Port)
/// </remarks>
public bool UseTls { get; init; }

/// <summary>
/// Whether, and how, to send the plaintext <c>MSSP-REQUEST</c> line this time.
/// </summary>
/// <remarks>
/// <para>
/// <b>Defaults to <see cref="PlaintextMsspAsk.Never"/></b>, the opposite direction from
/// <see cref="AwaitingCorroboration"/> and for the opposite reason: <c>IAC DO 70</c> 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 (<c>docs/codebase-survey-2026-07-30.md</c>).
/// A caller that cannot say what this address has done before does not type it.
/// </para>
/// <para>
/// Only the crawl loop answers it, from what the address did before (<c>crawl_target.mssp_route</c>,
/// see <c>MsspRoutes</c>): 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 <see cref="PlaintextMsspAsk.Trial"/>.
/// </para>
/// </remarks>
public PlaintextMsspAsk PlaintextMssp { get; init; } = PlaintextMsspAsk.Never;

public override string ToString() => Host.Contains(':') ? $"[{Host}]:{Port}" : $"{Host}:{Port}";
}

/// <summary>What a probe does about the plaintext <c>MSSP-REQUEST</c> form.</summary>
/// <remarks>
/// The plaintext form is the SMAUG family's (spec §6.4); TelnetNegotiationCore carries it as
/// <c>MSSPPlaintextProtocol</c>. 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.
/// </remarks>
public enum PlaintextMsspAsk
{
/// <summary>Not sent.</summary>
Never,

/// <summary>
/// Sent once, on a second short dial of its own, after an ordinary session that got no MSSP.
/// </summary>
/// <remarks>
/// 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, <c>WHO</c> included, is then typed into the wrong question. That would publish our
/// experiment as an unreadable <c>WHO</c> (rule 5). The trial dial asks for nothing else and hangs
/// up.
/// </remarks>
Trial,

/// <summary>
/// Sent in the ordinary session, at the connect screen and before <c>WHO</c>, because this address
/// has answered it before.
/// </summary>
/// <remarks>
/// Before <c>WHO</c> on purpose: the report usually carries <c>PLAYERS</c>, which is a count
/// <c>PublishedCountAsync</c> accepts in place of typing <c>WHO</c> at all.
/// </remarks>
Ask,
}

/// <summary>
/// What carried a probe session. Part of the result's provenance, and the whole of what
/// <c>EndpointKind.Tls</c> is written from.
Expand Down
Loading
Loading