Phil Christensen closed issue #1: Preserve IAC byte integrity for MUD OOB protocols over the SSH bridge at bubblehouse / sshelnet

Phil Christensen
2026-04-25T00:53:52Z

Context

django-moo#16 adds telnet-subnegotiation MUD protocols to the server (GMCP, MSSP, MTTS, MSP, GA/EOR, CHARSET). These are the out-of-band (OOB) channels that blind-accessible MUD clients (Mudlet + sound pack, VIP Mud, MUSH-Z, TinTin++) rely on for gagging, sound packs, virtual buffers, status readouts, and speedwalking.

sshelnet is the plain-telnet → SSH bridge that makes django-moo reachable from telnet-only clients. Once django-moo#16 lands, sshelnet has to faithfully carry the OOB bytes in both directions without corruption.

The problem

Current internal/proxy/proxy.go does two things after dialing:

session.Shell() — without first calling session.RequestPty(...)

io.Copy in both directions

Two concrete gaps for MUD OOB support:

1. No PTY / no TERM — django-moo never enters raw mode

django-moo's feat/mud-client-support (MR !23) triggers raw mode when the session's terminal type matches a MUD-client pattern (currently TERM=xterm-256-basic; #16 will broaden this via MTTS/TTYPE negotiation). Without a PTY request, asyncssh has no terminal type to report and the server falls through to rich mode (prompt_toolkit), which expects a real terminal and will misbehave for a pure-byte-stream client.

2. IAC byte integrity is unverified

IAC is 0xFF. Both directions of the proxy must carry 0xFF bytes unmodified:

Server → client: django-moo emits IAC SB 201 "Module.Name" <json> IAC SE (GMCP) and friends. Those bytes must reach the MUD client as-is so the client's telnet parser fires.

Client → server: MUD clients respond to negotiation (IAC WILL TTYPE, IAC SB 24 IS "Mudlet" IAC SE, GMCP command events, etc.). Those bytes must reach django-moo's IAC state machine as-is.

io.Copy itself is byte-faithful, but the layers above it may not be:

If we add a PTY request (fix for #1), PTY line-discipline cooking (ONLCR, ISIG, IEXTEN, ISTRIP) can mangle 0xFF, translate CRLF, strip the high bit, or interpret bytes as control characters. We'd need to request raw termios or use SSH window-change requests carefully.

Without a PTY, stdin close semantics differ between the Go SSH client and asyncssh server; the current TestSSHDisconnect test uses CloseWrite, which maps to SSH EOF on the channel — that should be fine, but worth confirming once the real django-moo server is on the other end.

Solution space

Ranked rough-cost vs. value, best on top:

A. Minimal-PTY + raw termios (recommended first pass)

Before session.Shell():

modes := ssh.TerminalModes{
ssh.ECHO: 0, // no local echo
ssh.ICANON: 0, // no canonical/line mode
ssh.ISIG: 0, // don't interpret signals
ssh.IEXTEN: 0, // no extended input processing
ssh.OPOST: 0, // no output post-processing (no ONLCR)
ssh.INPCK: 0,
ssh.ISTRIP: 0, // do not strip 8th bit
ssh.ICRNL: 0, // don't translate CR→NL on input
ssh.IXON: 0,
ssh.TTY_OP_ISPEED: 38400,
ssh.TTY_OP_OSPEED: 38400,
}
if err := session.RequestPty(termType, rows, cols, modes); err != nil {
return fmt.Errorf("request pty: %w", err)
}

termType should be something django-moo recognizes as "MUD-style client." For the current MR!23 world that's "xterm-256-basic". Once #16 lands, MTTS subnegotiation will let the server detect Mudlet/TinTin++/etc. from the actual client-identification bytes, and termType can be whatever the upstream MUD client announced to sshelnet (if any) — default to "xterm" as a safe fallback.

Rows/cols: NAWS is deferred in #16, so hardcoding 25×80 is fine for now.

Why this works for OOB: with OPOST/ICRNL/INLCR/ISTRIP disabled, 0xFF passes through unmolested, and there's no LF/CRLF translation on top of what django-moo already does deliberately in _chan_write(). The channel becomes a pure byte pipe with just enough PTY shape to make asyncssh happy.

B. SSH subsystem channel

Use session.RequestSubsystem("mud") instead of session.Shell(). Subsystems (same mechanism SFTP uses) are binary by convention and bypass PTY entirely. Requires a coordinated change on the django-moo side to register a subsystem handler — but it's the cleanest architectural answer.

Defer until (A) proves insufficient.

C. Secondary SSH channel for OOB, reverse tunnel, etc.

The opening prompt considered carrying OOB over an SSH reverse tunnel (ssh -R). That does work, but adds:

An extra listener on the client side

Port-allocation / cleanup concerns

Auth ambiguity for the listener

Sync between two independent byte streams

A secondary SSH channel on the same connection is lighter than a reverse tunnel, but still fragments the logical stream and forces sshelnet to multiplex two telnet-ish flows back into one TCP socket for the MUD client.

Neither is needed unless (A) and (B) both fail. If it turns out we need them, this issue should be closed out and a follow-up opened with specifics. I'd be surprised if we got there.

Tasks

Research/verify the current baseline:

Connect sshelnet (as-is) to a local django-moo running MR!23 feat/mud-client-support. Document the failure mode — specifically, does raw mode engage? What does the session look like on both sides? (My expectation: rich mode engages, prompt_toolkit confusedly renders to a terminal it doesn't have.)

Send a TERM=xterm-256-basic env request and a minimal PTY request from sshelnet. Observe whether raw mode engages. Measure byte integrity with a simple 0xFF-round-trip probe in both directions.

Implement Solution A:

Add RequestPty with raw termios before session.Shell() in internal/proxy/proxy.go.

Make termType configurable per-profile with a sensible default. Document in README.md.

Optional: forward the MUD client's announced TERM (if it negotiates one via telnet) instead of hardcoding — low priority, depends on whether we add any telnet-awareness to sshelnet at all.

Tests:

Upgrade startEchoSSHServer to accept a pty-req request and echo back whatever it receives — in particular preserving bytes 0x00–0xFF without translation.

Add TestIACPassthroughServerToClient: server sends IAC SB 201 "Core.Hello" {"name":"moo"} IAC SE; client receives the exact byte sequence.

Add TestIACPassthroughClientToServer: mirror, client sends IAC SB 24 IS "Mudlet" IAC SE; server receives the exact bytes.

Add TestNullBytePassthrough + TestHighBytePassthrough as byte-integrity canaries (0x00, 0x7F, 0x80, 0xFE, 0xFF).

Negative test: with ICANON enabled, confirm the integrity tests fail — locks in the termios configuration as load-bearing.

Integration smoke test against a real django-moo + MR!23 once the above is merged:

Confirm raw mode engages.

Confirm text flows both ways cleanly.

After django-moo#16 merges, confirm GMCP Core.Hello lands in a Mudlet-like telnet listener wired up on the sshelnet side, using a tiny Go telnet decoder in a test.

Acceptance criteria

io.Copy'd data across the bridge is 8-bit clean in both directions, including 0xFF (IAC), 0x00 (NUL), and the full 0x80–0xFE range.

Connecting sshelnet to django-moo's feat/mud-client-support server engages raw mode without any client-side TERM gymnastics.

Once django-moo#16 is live, a MUD client connecting through sshelnet receives GMCP/MSSP/MSP events and can round-trip MTTS/TTYPE responses.

CI is green.

Dependencies / ordering

Not blocked by django-moo#16 — Solution A can ship first. The server-side protocol work just becomes observable once both are live.

Useful to land in coordination with django-moo MR!23, since that's the first server-side change that actually cares about terminal type.

Out of scope

Making sshelnet itself telnet-aware (parsing IAC, rewriting negotiation). The whole design is that sshelnet stays a dumb byte pipe; protocol intelligence lives at both endpoints.

Reverse tunnels / secondary channels (unless Solution A+B both fail).

MCCP2/3 compression on the SSH side (SSH is already encrypted+compressed; double-compressing OOB is not useful).