Files
2026-09-08 18:21:10 +01:00

164 lines
5.1 KiB
Python

"""Telnet terminal I/O for the txt3 BBS.
Deliberately conservative: real telnet clients, netcat, PuTTY in raw mode and
Windows telnet all have to work, so we negotiate the bare minimum (suppress
go-ahead, refuse everything else) and strip IAC sequences from the byte stream
rather than trying to be a full RFC 854 implementation.
"""
import socket
IAC = 255
DONT = 254
DO = 253
WONT = 252
WILL = 251
SB = 250
SE = 240
ECHO = 1
SGA = 3
CRLF = b"\r\n"
class Hangup(Exception):
"""Raised when the peer disconnects or times out."""
class Term:
def __init__(self, sock, addr, idle_timeout=600):
self.s = sock
self.addr = addr
self.buf = b""
self.s.settimeout(idle_timeout)
# We will echo input ourselves only for passwords (as masking);
# otherwise let the client echo locally, which is what most do.
self._send_raw(bytes([IAC, WILL, SGA]))
self._send_raw(bytes([IAC, DO, SGA]))
# ------------------------------------------------------------- low level
def _send_raw(self, data):
try:
self.s.sendall(data)
except (BrokenPipeError, ConnectionResetError, OSError):
raise Hangup()
def write(self, text=""):
if isinstance(text, str):
# Telnet wants CRLF; also escape a literal IAC byte.
text = text.replace("\n", "\r\n").encode("utf-8", "replace")
self._send_raw(text.replace(bytes([IAC]), bytes([IAC, IAC])))
def line(self, text=""):
self.write(text + "\n")
# ------------------------------------------------------------- input
def _fill(self):
try:
chunk = self.s.recv(1024)
except socket.timeout:
raise Hangup()
except (ConnectionResetError, OSError):
raise Hangup()
if not chunk:
raise Hangup()
self.buf += chunk
def _pop_byte(self):
while not self.buf:
self._fill()
b = self.buf[0]
self.buf = self.buf[1:]
return b
def _read_char(self):
"""Return the next data byte, transparently consuming telnet commands."""
while True:
b = self._pop_byte()
if b != IAC:
return b
# IAC ...
c = self._pop_byte()
if c == IAC:
return IAC # escaped literal 255
if c in (DO, DONT, WILL, WONT):
opt = self._pop_byte()
# Refuse everything except SGA, which we already agreed.
if c == DO:
resp = WILL if opt == SGA else WONT
elif c == WILL:
resp = DO if opt == SGA else DONT
else:
resp = WONT if c == DO else DONT
self._send_raw(bytes([IAC, resp, opt]))
continue
if c == SB:
# swallow the subnegotiation up to IAC SE
prev = None
while True:
x = self._pop_byte()
if prev == IAC and x == SE:
break
prev = x
continue
# any other 2-byte command: ignore
continue
def read(self, prompt="", mask=False, maxlen=200):
"""Read one line of input. Handles backspace and bare CR or LF."""
if prompt:
self.write(prompt)
out = bytearray()
while True:
b = self._read_char()
if b in (13, 10): # CR or LF
# A CR is often followed by LF or NUL; peek and drop it.
if self.buf[:1] in (b"\n", b"\x00"):
self.buf = self.buf[1:]
self.write("\n")
break
if b in (8, 127): # backspace / delete
if out:
out.pop()
if mask:
self.write("\b \b")
else:
self.write("\b \b")
continue
if b == 3: # ^C
raise Hangup()
if b == 4 and not out: # ^D on an empty line
raise Hangup()
if b < 32 or b > 126:
continue # ignore other control/non-ascii
if len(out) >= maxlen:
continue
out.append(b)
if mask:
self.write("*")
return out.decode("utf-8", "replace").strip()
def ask(self, prompt, maxlen=200):
return self.read(prompt, mask=False, maxlen=maxlen)
def secret(self, prompt="Password: "):
return self.read(prompt, mask=True, maxlen=60)
# ------------------------------------------------------------- screen
def rule(self, ch="-", n=60):
self.line(ch * n)
def header(self, title):
self.line()
self.rule("=")
self.line(" " + title)
self.rule("=")
def pause(self):
self.read("\n[enter] ")
def close(self):
try:
self.s.close()
except OSError:
pass