From ae023bb7dd52d099dc6c1b07a7605d91c974545f Mon Sep 17 00:00:00 2001 From: Morquin Date: Wed, 17 Jun 2026 22:05:11 +0200 Subject: [PATCH] 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{