164 lines
5.1 KiB
Python
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
|