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{