164 lines
4.8 KiB
Go
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])
|
|
}
|
|
}
|
|
}
|