urit/internal/server/telnet.go
2026-05-02 21:11:50 -04:00

164 lines
4.8 KiB
Go

package server
// Telnet protocol constants.
// These are the IAC (Interpret As Command) sequences used to negotiate
// terminal options between server and client. In the original TAG-BBS,
// none of this existed — the serial port was a raw byte stream. Telnet
// adds a signaling layer on top of TCP that we need to handle.
const (
iacIAC byte = 255 // Interpret As Command — escapes the byte that follows
iacDONT byte = 254 // Demand the client stop using an option
iacDO byte = 253 // Request the client start using an option
iacWONT byte = 252 // Refuse to use an option
iacWILL byte = 251 // Offer to use an option
iacSB byte = 250 // Subnegotiation Begin
iacSE byte = 240 // Subnegotiation End
iacNOP byte = 241 // No Operation
iacGA byte = 249 // Go Ahead
// Telnet options we care about
optEcho byte = 1 // Server controls echo
optSGA byte = 3 // Suppress Go-Ahead (character-at-a-time mode)
optNAWS byte = 31 // Negotiate About Window Size
optTTYPE byte = 24 // Terminal Type
optLINEMODE byte = 34 // Linemode
)
// telnetNegotiation returns the IAC sequence to send at connection start.
// This puts the client into character-at-a-time mode with server-side echo,
// which is what a BBS needs for single-keypress menus.
//
// We send:
// - WILL ECHO: "I (server) will handle echoing characters back to you"
// - WILL SGA: "I won't send Go-Ahead signals" (enables character mode)
// - DO NAWS: "Please tell me your terminal dimensions"
// - DONT LINEMODE: "Don't use linemode" (reinforces character-at-a-time)
func telnetNegotiation() []byte {
return []byte{
iacIAC, iacWILL, optEcho,
iacIAC, iacWILL, optSGA,
iacIAC, iacDO, optNAWS,
iacIAC, iacDONT, optLINEMODE,
}
}
// telnetState tracks the IAC parser state for stripping telnet commands
// from the data stream. Without this, IAC sequences show up as garbage
// characters in user input.
type telnetState int
const (
tsData telnetState = iota // Normal data
tsIAC // Got IAC, next byte is command
tsWill // Got WILL, next byte is option
tsWont // Got WONT, next byte is option
tsDo // Got DO, next byte is option
tsDont // Got DONT, next byte is option
tsSB // Inside subnegotiation
tsSBIAC // Got IAC inside subnegotiation
)
// telnetFilter strips IAC sequences from raw telnet data and returns
// only the clean user input bytes. It also captures window size if the
// client sends NAWS subnegotiation.
//
// The filter is stateful — it tracks where it is in the IAC parse across
// calls, since IAC sequences can be split across TCP reads.
type telnetFilter struct {
state telnetState
sbBuffer []byte // Accumulates subnegotiation data
sbOption byte // Which option the subnegotiation is for
Width int // Terminal width from NAWS (0 = unknown)
Height int // Terminal height from NAWS (0 = unknown)
}
func newTelnetFilter() *telnetFilter {
return &telnetFilter{
state: tsData,
}
}
// Filter processes raw bytes from the TCP connection and returns only
// the clean data bytes (user input). Telnet commands are consumed silently.
func (f *telnetFilter) Filter(raw []byte) []byte {
clean := make([]byte, 0, len(raw))
for _, b := range raw {
switch f.state {
case tsData:
if b == iacIAC {
f.state = tsIAC
} else {
clean = append(clean, b)
}
case tsIAC:
switch b {
case iacIAC:
// Escaped 0xFF — literal data byte
clean = append(clean, 0xFF)
f.state = tsData
case iacWILL:
f.state = tsWill
case iacWONT:
f.state = tsWont
case iacDO:
f.state = tsDo
case iacDONT:
f.state = tsDont
case iacSB:
f.state = tsSB
f.sbBuffer = f.sbBuffer[:0]
case iacNOP, iacGA:
f.state = tsData
default:
// Unknown command, skip
f.state = tsData
}
case tsWill, tsWont, tsDo, tsDont:
// Option byte — consumed, back to data
f.state = tsData
case tsSB:
if b == iacIAC {
f.state = tsSBIAC
} else {
if len(f.sbBuffer) == 0 {
f.sbOption = b
}
f.sbBuffer = append(f.sbBuffer, b)
}
case tsSBIAC:
if b == iacSE {
// Subnegotiation complete
f.handleSubnegotiation()
f.state = tsData
} else {
// Escaped IAC within subnegotiation
f.sbBuffer = append(f.sbBuffer, b)
f.state = tsSB
}
}
}
return clean
}
// handleSubnegotiation processes a completed subnegotiation sequence.
func (f *telnetFilter) handleSubnegotiation() {
if len(f.sbBuffer) < 1 {
return
}
switch f.sbOption {
case optNAWS:
// NAWS: option(1) + width(2) + height(2) = 5 bytes
if len(f.sbBuffer) >= 5 {
f.Width = int(f.sbBuffer[1])<<8 | int(f.sbBuffer[2])
f.Height = int(f.sbBuffer[3])<<8 | int(f.sbBuffer[4])
}
}
}