From ae023bb7dd52d099dc6c1b07a7605d91c974545f Mon Sep 17 00:00:00 2001 From: Morquin Date: Wed, 17 Jun 2026 22:05:11 +0200 Subject: [PATCH 1/3] fix(telnet): stop Mudlet masking all input for the whole session Mudlet (and other local-echo clients) treat telnet WILL ECHO as a "mask this field" hint rather than a server-echo request. GoMud asserted WILL ECHO unconditionally at connect and never withdrew it, so Mudlet masked every keystroke for the entire session. Detect the client type at connect via an MNES NEW-ENVIRON probe and branch the echo behavior: - Mudlet: baseline WONT ECHO (Mudlet echoes locally); the server no longer echoes/masks per character or emits its own newline. Password prompts are masked by transiently asserting WILL ECHO and withdrawing it (WONT ECHO) once the step validates. - Raw telnet: unchanged - WILL ECHO baseline with server-side echo and mask-character passwords. - Web client: unchanged - TEXTMASK toggling, no server-side per-char echo. Detection is synchronous and bounded (200ms per read, 2s overall); a client that ignores NEW-ENVIRON falls back to non-Mudlet and still logs in. AI connections skip the probe and keep the historical baseline. Changes: - connections.ClientSettings: add IsMudlet / DetectionComplete. - ConnectionDetails.SetReadDeadline for the bounded probe reads. - term: NEW-ENVIRON sub-negotiation codes, request/response matchers, and a TelnetRequestMNESVars helper. - TelnetIACHandler: negotiate NEW-ENVIRON and record IsMudlet from CLIENT_NAME, plus a unit test for the response parser. - main.go: detectClientType probe and echo-baseline branch. - login_prompt_handler: skip server echo/mask and newline for local-echo clients; toggle ECHO masking around Mudlet password steps. --- internal/connections/clientsettings.go | 8 ++ internal/connections/connectiondetails.go | 11 ++ .../inputhandlers/login_prompt_handler.go | 75 ++++++++---- internal/inputhandlers/term_iac.go | 85 +++++++++++++ internal/inputhandlers/term_iac_test.go | 88 ++++++++++++++ internal/term/telnet.go | 11 ++ internal/term/term.go | 27 +++++ main.go | 112 ++++++++++++++++-- 8 files changed, 387 insertions(+), 30 deletions(-) create mode 100644 internal/inputhandlers/term_iac_test.go diff --git a/internal/connections/clientsettings.go b/internal/connections/clientsettings.go index 81e70086b..edeb6a022 100644 --- a/internal/connections/clientsettings.go +++ b/internal/connections/clientsettings.go @@ -12,6 +12,14 @@ type ClientSettings struct { // Is MSP enabled? MSPEnabled bool // Do they accept sound in their client? SendTelnetGoAhead bool // Defaults false, should we send a IAC GA after prompts? + // IsMudlet is true when the client identified itself as Mudlet (via the MNES + // NEW-ENVIRON CLIENT_NAME variable). Mudlet echoes input locally and treats + // the telnet ECHO option purely as a password-masking hint, so it must not + // receive server-side echo. + IsMudlet bool + // DetectionComplete is set once the connect-time client-type probe has + // resolved (either a NEW-ENVIRON reply arrived or the probe timed out). + DetectionComplete bool } func (c ClientSettings) IsMsp() bool { diff --git a/internal/connections/connectiondetails.go b/internal/connections/connectiondetails.go index ed9f616fb..1753b8682 100644 --- a/internal/connections/connectiondetails.go +++ b/internal/connections/connectiondetails.go @@ -324,6 +324,17 @@ func (cd *ConnectionDetails) Read(p []byte) (n int, err error) { return cd.conn.Read(p) } +// SetReadDeadline bounds how long the next Read may block. It only applies to +// the raw telnet path (net.Conn); SSH and WebSocket connections do not use the +// connect-time detection probe, so this is a no-op for them. Pass the zero time +// to clear a previously-set deadline. +func (cd *ConnectionDetails) SetReadDeadline(t time.Time) error { + if cd.conn != nil { + return cd.conn.SetReadDeadline(t) + } + return nil +} + func (cd *ConnectionDetails) Close() { if cd.heartbeat != nil { cd.heartbeat.stop() diff --git a/internal/inputhandlers/login_prompt_handler.go b/internal/inputhandlers/login_prompt_handler.go index aa99e8267..4b287e8aa 100644 --- a/internal/inputhandlers/login_prompt_handler.go +++ b/internal/inputhandlers/login_prompt_handler.go @@ -204,31 +204,41 @@ func CreatePromptHandler(steps []*PromptStep, onComplete CompletionFunc) connect // Handle printable characters //clientInput.Buffer = append(clientInput.Buffer, clientInput.DataIn...) - // Echo or Mask - if currentStep.MaskInput { - - // Cache the mask template string if needed - if state.maskTemplate == "" && currentStep.MaskTemplate != "" { - - if maskStr, err := templates.Process(currentStep.MaskTemplate, nil); err != nil { - mudlog.Error("Mask template error", "template", currentStep.MaskTemplate, "error", err) - state.maskTemplate = "*" // Fallback mask - } else { - state.maskTemplate = templates.AnsiParse(maskStr) + // Local-echo clients (Mudlet, web client) echo input themselves, + // so the server must not echo or emit mask characters per byte - + // doing so would double every character. Their masking is handled + // out-of-band (telnet ECHO toggling for Mudlet, TEXTMASK for the + // web client). Only raw telnet relies on this server-side echo. + cs := connections.GetClientSettings(clientInput.ConnectionId) + isLocalEcho := cs.IsMudlet || connections.IsWebsocket(clientInput.ConnectionId) + + if !isLocalEcho { + // Echo or Mask + if currentStep.MaskInput { + + // Cache the mask template string if needed + if state.maskTemplate == "" && currentStep.MaskTemplate != "" { + + if maskStr, err := templates.Process(currentStep.MaskTemplate, nil); err != nil { + mudlog.Error("Mask template error", "template", currentStep.MaskTemplate, "error", err) + state.maskTemplate = "*" // Fallback mask + } else { + state.maskTemplate = templates.AnsiParse(maskStr) + } + + } else if state.maskTemplate == "" { + state.maskTemplate = "*" // Default fallback if no template specified } - } else if state.maskTemplate == "" { - state.maskTemplate = "*" // Default fallback if no template specified - } + // Send mask character(s) + for i := 0; i < len(clientInput.DataIn); i++ { + connections.SendTo([]byte(state.maskTemplate), clientInput.ConnectionId) + } - // Send mask character(s) - for i := 0; i < len(clientInput.DataIn); i++ { - connections.SendTo([]byte(state.maskTemplate), clientInput.ConnectionId) + } else { + // Echo input directly + connections.SendTo(clientInput.DataIn, clientInput.ConnectionId) } - - } else { - // Echo input directly - connections.SendTo(clientInput.DataIn, clientInput.ConnectionId) } } @@ -258,7 +268,12 @@ func CreatePromptHandler(steps []*PromptStep, onComplete CompletionFunc) connect } // Enter Pressed: Process Input - connections.SendTo(term.CRLF, clientInput.ConnectionId) // Echo newline + // Mudlet echoes the submitted line (and its newline) locally, so a + // server-sent CRLF here would produce a blank line. Raw telnet + // (server-side echo) and the web client still need it. + if !connections.GetClientSettings(clientInput.ConnectionId).IsMudlet { + connections.SendTo(term.CRLF, clientInput.ConnectionId) // Echo newline + } submittedInput := strings.TrimSpace(string(clientInput.Buffer)) clientInput.Buffer = clientInput.Buffer[:0] // Clear buffer for next input state.maskTemplate = "" // Clear cached mask template @@ -309,6 +324,14 @@ func CreatePromptHandler(steps []*PromptStep, onComplete CompletionFunc) connect mudlog.Debug("Prompt Step Success", "step", currentStep.ID, "value", validatedValue, "connectionId", clientInput.ConnectionId) } + // A masked Mudlet step just passed: withdraw the telnet ECHO mask so + // input is visible again. If the next step is also masked, sendPrompt + // re-asserts WILL ECHO; if this was the last step, this restores normal + // echo before gameplay begins. + if currentStep.MaskInput && connections.GetClientSettings(clientInput.ConnectionId).IsMudlet { + connections.SendTo(term.TelnetWONT(term.TELNET_OPT_ECHO), clientInput.ConnectionId) + } + // Advance to Next Step or Complete if advanceAndSendPrompt(state, clientInput) { @@ -367,6 +390,14 @@ func sendPrompt(step *PromptStep, clientInput *connections.ClientInput, results connections.SendTo([]byte(maskCmd), clientInput.ConnectionId) } + // Mudlet reads telnet WILL ECHO as "mask this field". Assert it for masked + // steps (passwords) so Mudlet hides the input; it is withdrawn (WONT ECHO) + // once the step validates. Sent before the prompt text so masking is active + // the moment the prompt appears. + if step.MaskInput && connections.GetClientSettings(clientInput.ConnectionId).IsMudlet { + connections.SendTo(term.TelnetWILL(term.TELNET_OPT_ECHO), clientInput.ConnectionId) + } + connections.SendTo([]byte(parsedPrompt), clientInput.ConnectionId) } diff --git a/internal/inputhandlers/term_iac.go b/internal/inputhandlers/term_iac.go index 1b2dd3f52..3649eb710 100644 --- a/internal/inputhandlers/term_iac.go +++ b/internal/inputhandlers/term_iac.go @@ -1,6 +1,8 @@ package inputhandlers import ( + "strings" + "github.com/GoMudEngine/GoMud/internal/configs" "github.com/GoMudEngine/GoMud/internal/connections" "github.com/GoMudEngine/GoMud/internal/mudlog" @@ -148,6 +150,42 @@ func TelnetIACHandler(clientInput *connections.ClientInput, sharedState map[stri continue } + // + // NEW-ENVIRON / MNES client detection (see handleTelnetConnection in main.go). + // + + // Client agreed to NEW-ENVIRON: ask it for the MNES identity variables. + if ok, _ := term.Matches(iacCmd, term.TelnetWillNewEnviron); ok { + mudlog.Debug("Received", "type", "IAC (WILL NEW-ENVIRON)") + connections.SendTo(term.TelnetRequestMNESVars(), clientInput.ConnectionId) + continue + } + + // Client refused NEW-ENVIRON: it won't identify itself this way, so + // treat detection as complete (and not Mudlet). + if ok, _ := term.Matches(iacCmd, term.TelnetWontNewEnviron); ok { + mudlog.Debug("Received", "type", "IAC (WONT NEW-ENVIRON)") + + cs := connections.GetClientSettings(clientInput.ConnectionId) + cs.DetectionComplete = true + connections.OverwriteClientSettings(clientInput.ConnectionId, cs) + + continue + } + + // Client sent its NEW-ENVIRON variables. Scan for CLIENT_NAME=MUDLET. + if ok, payload := term.Matches(iacCmd, term.TelnetNewEnvironResponse); ok { + isMudlet := newEnvironIsMudlet(payload) + mudlog.Debug("Received", "type", "IAC (NEW-ENVIRON IS)", "isMudlet", isMudlet, "data", term.BytesString(payload)) + + cs := connections.GetClientSettings(clientInput.ConnectionId) + cs.IsMudlet = isMudlet + cs.DetectionComplete = true + connections.OverwriteClientSettings(clientInput.ConnectionId, cs) + + continue + } + // Unhanlded IAC command, log it mudlog.Debug("Received", "type", "IAC (Unhandled)", "size", len(clientInput.DataIn), "data", term.TelnetCommandToString(iacCmd)) @@ -156,3 +194,50 @@ func TelnetIACHandler(clientInput *connections.ClientInput, sharedState map[stri // We handled it, so don't pass it on return false } + +// newEnvironIsMudlet walks a NEW-ENVIRON IS payload (the bytes between +// `IAC SB NEW-ENVIRON IS` and the trailing `IAC SE`) looking for the MNES +// CLIENT_NAME variable. It returns true when CLIENT_NAME equals "MUDLET" +// (case-insensitive). The payload is a sequence of VAR/USERVAR name segments, +// each optionally followed by a VALUE segment; segments are delimited by the +// VAR(0)/VALUE(1)/USERVAR(3) control bytes. +func newEnvironIsMudlet(payload []byte) bool { + isControl := func(b byte) bool { + return b == term.TELNET_NEWENV_VAR || b == term.TELNET_NEWENV_VALUE || b == term.TELNET_NEWENV_USERVAR + } + + i := 0 + for i < len(payload) { + code := payload[i] + i++ + + // Only VAR / USERVAR start a named variable. + if code != term.TELNET_NEWENV_VAR && code != term.TELNET_NEWENV_USERVAR { + continue + } + + // Read the variable name up to the next control byte. + nameStart := i + for i < len(payload) && !isControl(payload[i]) { + i++ + } + name := string(payload[nameStart:i]) + + // An optional VALUE segment follows. + value := "" + if i < len(payload) && payload[i] == term.TELNET_NEWENV_VALUE { + i++ + valueStart := i + for i < len(payload) && !isControl(payload[i]) { + i++ + } + value = string(payload[valueStart:i]) + } + + if name == "CLIENT_NAME" && strings.EqualFold(value, "MUDLET") { + return true + } + } + + return false +} diff --git a/internal/inputhandlers/term_iac_test.go b/internal/inputhandlers/term_iac_test.go new file mode 100644 index 000000000..733cdaafc --- /dev/null +++ b/internal/inputhandlers/term_iac_test.go @@ -0,0 +1,88 @@ +package inputhandlers + +import ( + "testing" + + "github.com/GoMudEngine/GoMud/internal/term" +) + +// newEnvironPayload builds the bytes that appear between `IAC SB NEW-ENVIRON IS` +// and the trailing `IAC SE` for a sequence of VAR/VALUE pairs. +func newEnvironPayload(pairs ...[2]string) []byte { + out := []byte{} + for _, p := range pairs { + out = append(out, term.TELNET_NEWENV_VAR) + out = append(out, []byte(p[0])...) + out = append(out, term.TELNET_NEWENV_VALUE) + out = append(out, []byte(p[1])...) + } + return out +} + +func TestNewEnvironIsMudlet(t *testing.T) { + tests := []struct { + name string + payload []byte + want bool + }{ + { + name: "mudlet with version", + payload: newEnvironPayload([2]string{"CLIENT_NAME", "Mudlet"}, [2]string{"CLIENT_VERSION", "4.17.2"}), + want: true, + }, + { + name: "mudlet uppercase value", + payload: newEnvironPayload([2]string{"CLIENT_NAME", "MUDLET"}), + want: true, + }, + { + name: "client name not first", + payload: newEnvironPayload([2]string{"CLIENT_VERSION", "4.17.2"}, [2]string{"CLIENT_NAME", "Mudlet"}), + want: true, + }, + { + name: "different client", + payload: newEnvironPayload([2]string{"CLIENT_NAME", "TinTin++"}), + want: false, + }, + { + name: "client name without value", + payload: append([]byte{term.TELNET_NEWENV_VAR}, []byte("CLIENT_NAME")...), + want: false, + }, + { + name: "empty payload", + payload: []byte{}, + want: false, + }, + { + name: "uservar segment ignored, var matched", + payload: append(append([]byte{term.TELNET_NEWENV_USERVAR}, []byte("FOO")...), newEnvironPayload([2]string{"CLIENT_NAME", "Mudlet"})...), + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := newEnvironIsMudlet(tc.payload); got != tc.want { + t.Errorf("newEnvironIsMudlet() = %v, want %v", got, tc.want) + } + }) + } +} + +// TestNewEnvironResponseMatcher verifies the term matcher extracts the payload +// that newEnvironIsMudlet expects from a full client response frame. +func TestNewEnvironResponseMatcher(t *testing.T) { + frame := term.TelnetNewEnvironResponse.BytesWithPayload( + newEnvironPayload([2]string{"CLIENT_NAME", "Mudlet"}), + ) + + ok, payload := term.Matches(frame, term.TelnetNewEnvironResponse) + if !ok { + t.Fatalf("expected frame to match TelnetNewEnvironResponse") + } + if !newEnvironIsMudlet(payload) { + t.Errorf("expected extracted payload to be detected as Mudlet, payload=%v", payload) + } +} diff --git a/internal/term/telnet.go b/internal/term/telnet.go index 4d71c538a..3747ba616 100644 --- a/internal/term/telnet.go +++ b/internal/term/telnet.go @@ -132,6 +132,17 @@ const ( TELNET_OPT_PRAGMA_HEARTBEAT IACByte = 140 // TELOPT PRAGMA HEARTBEAT RFC: TELNET_OPT_254 IACByte = 254 TELNET_OPT_EXTENDED_OPT IACByte = 255 // Extended-Options-List RFC: https://www.ietf.org/rfc/rfc861.txt + + // NEW-ENVIRON (option 39) sub-negotiation codes. RFC: https://www.ietf.org/rfc/rfc1572.txt + // These are reused by the MNES (Mud New-Environ Standard) handshake that we + // use to detect Mudlet (and other clients that advertise CLIENT_NAME). + TELNET_NEWENV_IS IACByte = 0 // Client -> Server: here are my variables + TELNET_NEWENV_SEND IACByte = 1 // Server -> Client: please send these variables + TELNET_NEWENV_INFO IACByte = 2 // Client -> Server: a variable changed + TELNET_NEWENV_VAR IACByte = 0 // A well-known variable name follows + TELNET_NEWENV_VALUE IACByte = 1 // A variable value follows + TELNET_NEWENV_ESC IACByte = 2 // Escape the next byte (it is data, not a code) + TELNET_NEWENV_USERVAR IACByte = 3 // A user-defined variable name follows ) // https://users.cs.cf.ac.uk/Dave.Marshall/Internet/node142.html diff --git a/internal/term/term.go b/internal/term/term.go index d05f06828..831b8063e 100644 --- a/internal/term/term.go +++ b/internal/term/term.go @@ -80,6 +80,22 @@ var ( // Go Ahead TelnetGoAhead = TerminalCommand{[]byte{TELNET_IAC, TELNET_GA}, []byte{}} + // + // NEW-ENVIRON / MNES (used to detect the client at connect time) + // + // Ask the client to enable NEW-ENVIRON. A compliant client replies with + // TelnetWillNewEnviron (or TelnetWontNewEnviron to refuse). + TelnetRequestNewEnviron = TerminalCommand{[]byte{TELNET_IAC, TELNET_DO, TELNET_OPT_NEW_ENV}, []byte{}} + // Client agrees / refuses to use NEW-ENVIRON. + TelnetWillNewEnviron = TerminalCommand{[]byte{TELNET_IAC, TELNET_WILL, TELNET_OPT_NEW_ENV}, []byte{}} + TelnetWontNewEnviron = TerminalCommand{[]byte{TELNET_IAC, TELNET_WONT, TELNET_OPT_NEW_ENV}, []byte{}} + // Server -> Client request for variables. Payload is a VAR-prefixed list of + // variable names (see TelnetRequestMNESVars). + TelnetNewEnvironSendRequest = TerminalCommand{[]byte{TELNET_IAC, TELNET_SB, TELNET_OPT_NEW_ENV, TELNET_NEWENV_SEND}, []byte{TELNET_IAC, TELNET_SE}} + // Client -> Server response carrying the requested variable values. Payload + // is a sequence of VAR/VALUE pairs. + TelnetNewEnvironResponse = TerminalCommand{[]byte{TELNET_IAC, TELNET_SB, TELNET_OPT_NEW_ENV, TELNET_NEWENV_IS}, []byte{TELNET_IAC, TELNET_SE}} + /////////////////////////// // ANSI COMMANDS /////////////////////////// @@ -188,6 +204,17 @@ var ( AnsiSetBellDuration = TerminalCommand{[]byte{ANSI_ESC, '[', '1', '1', ';'}, []byte{']'}} ) +// TelnetRequestMNESVars builds the NEW-ENVIRON SEND request that asks the +// client for the standard MNES identification variables. Mudlet replies with +// CLIENT_NAME=Mudlet, which is how we recognise it. +func TelnetRequestMNESVars() []byte { + payload := []byte{TELNET_NEWENV_VAR} + payload = append(payload, []byte("CLIENT_NAME")...) + payload = append(payload, TELNET_NEWENV_VAR) + payload = append(payload, []byte("CLIENT_VERSION")...) + return TelnetNewEnvironSendRequest.BytesWithPayload(payload) +} + func IsTelnetCommand(b []byte) bool { return len(b) > 0 && b[0] == TELNET_IAC } diff --git a/main.go b/main.go index b2f702863..75e1faccf 100644 --- a/main.go +++ b/main.go @@ -604,6 +604,81 @@ func resumeRestoredConnection(connDetails *connections.ConnectionDetails, userOb } } +// Bounds for the connect-time client-type detection probe. +const ( + clientDetectTimeout = 2 * time.Second // overall budget before defaulting to non-Mudlet + clientDetectReadDeadline = 200 * time.Millisecond // per-read slice so we can re-check the budget +) + +// detectClientType probes a raw telnet client with an MNES NEW-ENVIRON request +// and synchronously waits (with a bounded timeout) for the reply so the caller +// can choose the correct echo baseline before the first prompt is shown. +// +// Replies are fed through the normal input handler chain, where TelnetIACHandler +// performs the NEW-ENVIRON negotiation and records IsMudlet/DetectionComplete on +// the connection's client settings. The login handler is intentionally not in +// the chain yet, so stray pre-login input is simply buffered (not echoed) and is +// preserved for the main loop. On timeout the client defaults to non-Mudlet. +// +// AI connections skip the probe entirely and keep the historical raw-telnet +// baseline, so automated clients are not delayed by the detection window. +func detectClientType(connDetails *connections.ConnectionDetails, clientInput *connections.ClientInput, sharedState map[string]any) { + + connId := connDetails.ConnectionId() + + if connDetails.ConnType() == connections.ConnAI { + cs := connections.GetClientSettings(connId) + cs.DetectionComplete = true + connections.OverwriteClientSettings(connId, cs) + return + } + + // Ask the client to enable NEW-ENVIRON. A Mudlet client replies WILL, which + // TelnetIACHandler answers with the MNES SEND request; Mudlet then returns + // its CLIENT_NAME. Non-Mudlet clients reply WONT (or never reply -> timeout). + connections.SendTo(term.TelnetRequestNewEnviron.BytesWithPayload(nil), connId) + + buf := make([]byte, connections.ReadBufferSize) + deadline := time.Now().Add(clientDetectTimeout) + + for { + if connections.GetClientSettings(connId).DetectionComplete { + break + } + if !time.Now().Before(deadline) { + break + } + + _ = connDetails.SetReadDeadline(time.Now().Add(clientDetectReadDeadline)) + + n, err := connDetails.Read(buf) + if err != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + continue // no data this slice; re-check the overall budget + } + break // a real read error; let the main loop surface it + } + if n == 0 { + continue + } + + clientInput.DataIn = buf[:n] + if _, lastHandler, herr := connDetails.HandleInput(clientInput, sharedState); herr != nil { + mudlog.Warn("Client detection input", "handler", lastHandler, "connectionId", connId, "error", herr) + } + } + + // Restore blocking reads for the main loop. + _ = connDetails.SetReadDeadline(time.Time{}) + + // Ensure detection is flagged complete even if we timed out. + cs := connections.GetClientSettings(connId) + if !cs.DetectionComplete { + cs.DetectionComplete = true + connections.OverwriteClientSettings(connId, cs) + } +} + func handleTelnetConnection(connDetails *connections.ConnectionDetails, wg *sync.WaitGroup) { defer func() { wg.Done() @@ -625,8 +700,10 @@ func handleTelnetConnection(connDetails *connections.ConnectionDetails, wg *sync connDetails.AddInputHandler("CleanserInputHandler", inputhandlers.CleanserInputHandler) connDetails.AddInputHandler("TextPrefixHandler", inputhandlers.TextPrefixHandler) - loginHandler := inputhandlers.GetLoginPromptHandler() // Get the configured handler func - connDetails.AddInputHandler("LoginPromptHandler", loginHandler) // Add it with a unique name + // Get the configured login handler func. It is added to the input handler + // chain further down, AFTER client-type detection, so that the connect-time + // echo baseline is correct before the very first (username) prompt is shown. + loginHandler := inputhandlers.GetLoginPromptHandler() // Turn off "line at a time", send chars as typed connections.SendTo( @@ -639,13 +716,12 @@ func handleTelnetConnection(connDetails *connections.ConnectionDetails, wg *sync connDetails.ConnectionId(), ) - // Tell the client we intend to echo back what they type - // So they shouldn't locally echo it + // NOTE: The echo negotiation (WILL/WONT ECHO) is deliberately NOT sent here. + // It depends on the client type, which we detect below before showing the + // first prompt. Raw telnet clients rely on server-side echo (WILL ECHO), + // while local-echo clients such as Mudlet must start at WONT ECHO. See the + // detection block further down. - connections.SendTo( - term.TelnetWILL(term.TELNET_OPT_ECHO), - connDetails.ConnectionId(), - ) // Request that the client report window size changes as they happen connections.SendTo( term.TelnetDO(term.TELNET_OPT_NAWS), @@ -716,6 +792,26 @@ func handleTelnetConnection(connDetails *connections.ConnectionDetails, wg *sync connections.SendTo([]byte("\r\nThis port is for AI clients. Human players, please use the standard telnet port.\r\n\r\n"), connDetails.ConnectionId()) } + // --- Detect the client type and pick the connect-time echo baseline --- + // Local-echo clients such as Mudlet echo input themselves and read telnet + // ECHO purely as a "mask this field" hint, so they must start at WONT ECHO + // (and only see WILL ECHO transiently around password prompts). Raw telnet + // clients rely on server-side echo, so they keep the historical WILL ECHO. + detectClientType(connDetails, clientInput, sharedState) + + clientCS := connections.GetClientSettings(connDetails.ConnectionId()) + if clientCS.IsMudlet { + // Mudlet does its own local echo -> baseline is WONT ECHO. + connections.SendTo(term.TelnetWONT(term.TELNET_OPT_ECHO), connDetails.ConnectionId()) + } else { + // Raw telnet keeps server-side echo (unchanged behavior). + connections.SendTo(term.TelnetWILL(term.TELNET_OPT_ECHO), connDetails.ConnectionId()) + } + + // Now that the echo baseline is correct, add the login handler so the first + // prompt is rendered with the right masking behavior. + connDetails.AddInputHandler("LoginPromptHandler", loginHandler) + // --- Trigger the Prompt Handler to initialize state and send the FIRST prompt --- // Create a dummy input that signifies "start the process" but has no actual user data/control codes. initialTriggerInput := &connections.ClientInput{ From d4b4afee646a94eb468971755b1ddda2ff26615c Mon Sep 17 00:00:00 2001 From: Morquin Date: Wed, 17 Jun 2026 22:36:33 +0200 Subject: [PATCH 2/3] term: alias NEW-ENVIRON codes to existing telnet constants Address review feedback on PR #633: the NEW-ENVIRON sub-negotiation codes share byte values with telnet option constants already defined in this block, so alias them instead of re-declaring literals. Values are unchanged (IS/VAR=0, SEND/VALUE=1, INFO/ESC=2, USERVAR=3), so behavior is identical. --- internal/term/telnet.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/internal/term/telnet.go b/internal/term/telnet.go index 3747ba616..48b5e8064 100644 --- a/internal/term/telnet.go +++ b/internal/term/telnet.go @@ -134,15 +134,17 @@ const ( TELNET_OPT_EXTENDED_OPT IACByte = 255 // Extended-Options-List RFC: https://www.ietf.org/rfc/rfc861.txt // NEW-ENVIRON (option 39) sub-negotiation codes. RFC: https://www.ietf.org/rfc/rfc1572.txt - // These are reused by the MNES (Mud New-Environ Standard) handshake that we - // use to detect Mudlet (and other clients that advertise CLIENT_NAME). - TELNET_NEWENV_IS IACByte = 0 // Client -> Server: here are my variables - TELNET_NEWENV_SEND IACByte = 1 // Server -> Client: please send these variables - TELNET_NEWENV_INFO IACByte = 2 // Client -> Server: a variable changed - TELNET_NEWENV_VAR IACByte = 0 // A well-known variable name follows - TELNET_NEWENV_VALUE IACByte = 1 // A variable value follows - TELNET_NEWENV_ESC IACByte = 2 // Escape the next byte (it is data, not a code) - TELNET_NEWENV_USERVAR IACByte = 3 // A user-defined variable name follows + // Used by the MNES (Mud New-Environ Standard) handshake that we use to detect + // Mudlet (and other clients that advertise CLIENT_NAME). Their byte values + // coincide with telnet option codes already defined above, so they are aliased + // to those constants instead of being re-declared as literals. + TELNET_NEWENV_IS = TELNET_OPT_TXBIN // 0: Client -> Server: here are my variables + TELNET_NEWENV_SEND = TELNET_OPT_ECHO // 1: Server -> Client: please send these variables + TELNET_NEWENV_INFO = TELNET_OPT_RECONN // 2: Client -> Server: a variable changed + TELNET_NEWENV_VAR = TELNET_OPT_TXBIN // 0: A well-known variable name follows + TELNET_NEWENV_VALUE = TELNET_NEWENV_SEND // 1: A variable value follows + TELNET_NEWENV_ESC = TELNET_NEWENV_INFO // 2: Escape the next byte (it is data, not a code) + TELNET_NEWENV_USERVAR = TELNET_OPT_SUP_GO_AHD // 3: A user-defined variable name follows ) // https://users.cs.cf.ac.uk/Dave.Marshall/Internet/node142.html From 80cc2c2c45dda70a16fc07dddb6731baf2c7e3fe Mon Sep 17 00:00:00 2001 From: Morquin Date: Wed, 17 Jun 2026 23:24:27 +0200 Subject: [PATCH 3/3] telnet: offer EOR and broaden NEW-ENVIRON detection for Mudlet masking Real-Mudlet testing showed password masking half-working: Mudlet honored WILL ECHO (command echo suppressed) but did not put asterisks on the input line. Aligns the connect/detection sequence with a known-working downstream fork: - Offer WILL EOR at connect. Mudlet prefers EOR over GA to delimit prompts; with SUPPRESS-GO-AHEAD set and no EOR, Mudlet has no prompt anchor for its input-line masking. - Request ALL NEW-ENVIRON variables (empty SEND) instead of naming specific ones - broadly compatible; Mudlet returns CLIENT_NAME among them. - Match the NEW-ENVIRON IS response on the 'IAC SB NEW-ENVIRON IS' prefix alone (tolerant of TCP segmentation / trailing bytes), and make the parser treat IAC as a name/value terminator so a trailing IAC SE never bleeds into the final variable's value. --- internal/inputhandlers/term_iac.go | 16 ++++++++++------ internal/inputhandlers/term_iac_test.go | 10 +++++++--- internal/term/term.go | 25 +++++++++---------------- main.go | 9 +++++++++ 4 files changed, 35 insertions(+), 25 deletions(-) diff --git a/internal/inputhandlers/term_iac.go b/internal/inputhandlers/term_iac.go index 3649eb710..6170f5b07 100644 --- a/internal/inputhandlers/term_iac.go +++ b/internal/inputhandlers/term_iac.go @@ -154,10 +154,12 @@ func TelnetIACHandler(clientInput *connections.ClientInput, sharedState map[stri // NEW-ENVIRON / MNES client detection (see handleTelnetConnection in main.go). // - // Client agreed to NEW-ENVIRON: ask it for the MNES identity variables. + // Client agreed to NEW-ENVIRON: ask it to send all of its variables. + // (Requesting all is more broadly compatible than naming specific vars; + // Mudlet replies with CLIENT_NAME among them.) if ok, _ := term.Matches(iacCmd, term.TelnetWillNewEnviron); ok { mudlog.Debug("Received", "type", "IAC (WILL NEW-ENVIRON)") - connections.SendTo(term.TelnetRequestMNESVars(), clientInput.ConnectionId) + connections.SendTo(term.TelnetNewEnvironSendRequest.BytesWithPayload(nil), clientInput.ConnectionId) continue } @@ -195,15 +197,17 @@ func TelnetIACHandler(clientInput *connections.ClientInput, sharedState map[stri return false } -// newEnvironIsMudlet walks a NEW-ENVIRON IS payload (the bytes between -// `IAC SB NEW-ENVIRON IS` and the trailing `IAC SE`) looking for the MNES +// newEnvironIsMudlet walks a NEW-ENVIRON IS payload looking for the MNES // CLIENT_NAME variable. It returns true when CLIENT_NAME equals "MUDLET" // (case-insensitive). The payload is a sequence of VAR/USERVAR name segments, // each optionally followed by a VALUE segment; segments are delimited by the -// VAR(0)/VALUE(1)/USERVAR(3) control bytes. +// VAR(0)/VALUE(1)/USERVAR(3) control bytes. IAC (255) is also treated as a +// terminator so a trailing `IAC SE` left in the payload by the lenient matcher +// never bleeds into the final variable's value. func newEnvironIsMudlet(payload []byte) bool { isControl := func(b byte) bool { - return b == term.TELNET_NEWENV_VAR || b == term.TELNET_NEWENV_VALUE || b == term.TELNET_NEWENV_USERVAR + return b == term.TELNET_NEWENV_VAR || b == term.TELNET_NEWENV_VALUE || + b == term.TELNET_NEWENV_USERVAR || b == term.TELNET_IAC } i := 0 diff --git a/internal/inputhandlers/term_iac_test.go b/internal/inputhandlers/term_iac_test.go index 733cdaafc..b29dd8456 100644 --- a/internal/inputhandlers/term_iac_test.go +++ b/internal/inputhandlers/term_iac_test.go @@ -72,16 +72,20 @@ func TestNewEnvironIsMudlet(t *testing.T) { } // TestNewEnvironResponseMatcher verifies the term matcher extracts the payload -// that newEnvironIsMudlet expects from a full client response frame. +// that newEnvironIsMudlet expects from a full client response frame, including +// the trailing IAC SE terminator that the lenient matcher leaves in the payload. func TestNewEnvironResponseMatcher(t *testing.T) { - frame := term.TelnetNewEnvironResponse.BytesWithPayload( - newEnvironPayload([2]string{"CLIENT_NAME", "Mudlet"}), + // IAC SB NEW-ENVIRON IS IAC SE + frame := append( + term.TelnetNewEnvironResponse.BytesWithPayload(newEnvironPayload([2]string{"CLIENT_NAME", "Mudlet"})), + term.TELNET_IAC, term.TELNET_SE, ) ok, payload := term.Matches(frame, term.TelnetNewEnvironResponse) if !ok { t.Fatalf("expected frame to match TelnetNewEnvironResponse") } + // The trailing IAC SE remains in payload; the parser must still detect Mudlet. if !newEnvironIsMudlet(payload) { t.Errorf("expected extracted payload to be detected as Mudlet, payload=%v", payload) } diff --git a/internal/term/term.go b/internal/term/term.go index 831b8063e..50b304f79 100644 --- a/internal/term/term.go +++ b/internal/term/term.go @@ -89,12 +89,16 @@ var ( // Client agrees / refuses to use NEW-ENVIRON. TelnetWillNewEnviron = TerminalCommand{[]byte{TELNET_IAC, TELNET_WILL, TELNET_OPT_NEW_ENV}, []byte{}} TelnetWontNewEnviron = TerminalCommand{[]byte{TELNET_IAC, TELNET_WONT, TELNET_OPT_NEW_ENV}, []byte{}} - // Server -> Client request for variables. Payload is a VAR-prefixed list of - // variable names (see TelnetRequestMNESVars). + // Server -> Client request for variables. Sent with an empty payload to + // request ALL of the client's environment variables (broadly compatible; + // Mudlet replies with CLIENT_NAME among them). TelnetNewEnvironSendRequest = TerminalCommand{[]byte{TELNET_IAC, TELNET_SB, TELNET_OPT_NEW_ENV, TELNET_NEWENV_SEND}, []byte{TELNET_IAC, TELNET_SE}} - // Client -> Server response carrying the requested variable values. Payload - // is a sequence of VAR/VALUE pairs. - TelnetNewEnvironResponse = TerminalCommand{[]byte{TELNET_IAC, TELNET_SB, TELNET_OPT_NEW_ENV, TELNET_NEWENV_IS}, []byte{TELNET_IAC, TELNET_SE}} + // Client -> Server response carrying the variable values. EndChars are left + // empty so the match succeeds on the `IAC SB NEW-ENVIRON IS` prefix alone - + // the trailing IAC SE is tolerated inside the payload and ignored by the + // parser. This is more robust to TCP segmentation and trailing bytes than + // requiring an exact terminator. + TelnetNewEnvironResponse = TerminalCommand{[]byte{TELNET_IAC, TELNET_SB, TELNET_OPT_NEW_ENV, TELNET_NEWENV_IS}, []byte{}} /////////////////////////// // ANSI COMMANDS @@ -204,17 +208,6 @@ var ( AnsiSetBellDuration = TerminalCommand{[]byte{ANSI_ESC, '[', '1', '1', ';'}, []byte{']'}} ) -// TelnetRequestMNESVars builds the NEW-ENVIRON SEND request that asks the -// client for the standard MNES identification variables. Mudlet replies with -// CLIENT_NAME=Mudlet, which is how we recognise it. -func TelnetRequestMNESVars() []byte { - payload := []byte{TELNET_NEWENV_VAR} - payload = append(payload, []byte("CLIENT_NAME")...) - payload = append(payload, TELNET_NEWENV_VAR) - payload = append(payload, []byte("CLIENT_VERSION")...) - return TelnetNewEnvironSendRequest.BytesWithPayload(payload) -} - func IsTelnetCommand(b []byte) bool { return len(b) > 0 && b[0] == TELNET_IAC } diff --git a/main.go b/main.go index 75e1faccf..7e35190af 100644 --- a/main.go +++ b/main.go @@ -745,6 +745,15 @@ func handleTelnetConnection(connDetails *connections.ConnectionDetails, wg *sync connDetails.ConnectionId(), ) + // Offer EOR (End Of Record) support. Mudlet prefers EOR over GA and uses it + // to delimit prompts; without it (and with SUPPRESS-GO-AHEAD set) Mudlet has + // no prompt anchor, which can prevent its input-line password masking from + // engaging even though it honors the ECHO option. + connections.SendTo( + term.TelnetWILL(term.TELNET_OPT_EOR), + connDetails.ConnectionId(), + ) + clientSetupCommands := "" + //term.AnsiAltModeStart.String() + // alternative mode (No scrollback) //term.AnsiCursorHide.String() + // Hide Cursor (Because we will manually echo back) //term.AnsiCharSetUTF8.String() + // UTF8 mode