Skip to content

Commit c0ef0ea

Browse files
authored
LINEMODE negotiation fixes (jquast#131)
Finish our "LINEMODE" telnet option negotiation by enabling full feature support in our "telsh" server shell and design a compliant "LinemodeBuffer" for compliant client shell so that it may be fully exercised. Although LINEMODE was mostly implemented, there were a number of bugs that prevented it from negotiating correctly. However, both our server and client take a passive approach to LINEMODE, and only negotiate for it when asked.
1 parent 5be4f8e commit c0ef0ea

11 files changed

Lines changed: 777 additions & 49 deletions

docs/history.rst

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
11
History
22
=======
3-
3.0.3 (unreleased)
4-
* new: :func:`~telnetlib3.accessories.make_logger` accepts a ``filemode``
5-
parameter (``"a"`` append or ``"w"`` overwrite) so callers can control
6-
whether log files are truncated on each run. The default remains
7-
``"a"`` (append), matching historical behaviour.
8-
* new: ``--logfile-mode {append,rewrite}`` CLI flag controls whether the
9-
log file is appended to (default) or overwritten on each connection.
10-
* new: ``--typescript-mode {append,rewrite}`` CLI flag controls whether the
11-
typescript file is appended to (default) or overwritten on each connection.
12-
* bugfix: kludge mode not detected when server negotiates WILL ECHO + DO SGA
13-
(requesting client WILL SGA) instead of WILL ECHO + WILL SGA.
14-
:attr:`~telnetlib3.stream_writer.TelnetWriter.mode` and
15-
``_server_will_sga()`` now check SGA in either direction
16-
(``remote_option`` or ``local_option``), so clients correctly switch from
17-
line mode to character-at-a-time mode with these servers.
3+
3.0.3
4+
* bugfix: server and client now correctly complete LINEMODE negotiation when prompted to.
5+
* new: ``--logfile-mode {append,rewrite}`` and ``--typescript-mode`` CLI flags
6+
and :func:`~telnetlib3.accessories.make_logger` ``filemode`` argument control whether the log
7+
file is appended to (default) or overwritten on each connection.
8+
* new: :class:`~telnetlib3.client_shell.LinemodeBuffer` used by ``telnetlib3-client``, a
9+
client-side line buffer for LINEMODE EDIT mode with local erase-char, erase-line, erase-word
10+
editing, forwardmask flushing, and TRAPSIG IAC command generation. The default 'telsh' server
11+
was also updated to support linemode.
1812

1913
3.0.2
2014
* bugfix: :meth:`~telnetlib3.stream_writer.TelnetWriter.request_charset` raised :exc:`TypeError`,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "telnetlib3"
7-
version = "3.0.2" # Keep in sync with telnetlib3/accessories.py::get_version !
7+
version = "3.0.3" # Keep in sync with telnetlib3/accessories.py::get_version !
88
description = " Python Telnet server and client CLI and Protocol library"
99
readme = "README.rst"
1010
license = "ISC"

telnetlib3/accessories.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242

4343
def get_version() -> str:
4444
"""Return the current version of telnetlib3."""
45-
return "3.0.2" # keep in sync with pyproject.toml !
45+
return "3.0.3" # keep in sync with pyproject.toml !
4646

4747

4848
def encoding_from_lang(lang: str) -> Optional[str]:

telnetlib3/client_shell.py

Lines changed: 154 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
from dataclasses import dataclass
1111

1212
# local
13+
from . import slc as slc_module
1314
from . import accessories
1415
from ._session_context import TelnetSessionContext
1516

1617
log = logging.getLogger(__name__)
1718

1819
# local
20+
from .telopt import LINEMODE # noqa: E402
1921
from .accessories import TRACE # noqa: E402
2022
from .stream_reader import TelnetReader, TelnetReaderUnicode # noqa: E402
2123
from .stream_writer import TelnetWriter, TelnetWriterUnicode # noqa: E402
@@ -180,6 +182,93 @@ class _RawLoopState:
180182
reactivate_repl: bool = False
181183

182184

185+
class LinemodeBuffer:
186+
"""
187+
Client-side line buffer for LINEMODE EDIT mode (RFC 1184 §3.1).
188+
189+
Accumulates characters typed by the user, applying local SLC editing functions (erase-char,
190+
erase-line, erase-word) and transmitting complete lines to the server. When TRAPSIG is enabled,
191+
signal characters (^C etc.) are sent as IAC commands instead of buffered.
192+
193+
:param slctab: The writer's current SLC character table.
194+
:param forwardmask: FORWARDMASK received from server, or None.
195+
:param trapsig: When True, signal characters are sent as IAC commands.
196+
"""
197+
198+
def __init__(
199+
self,
200+
slctab: Dict[bytes, slc_module.SLC],
201+
forwardmask: Optional[slc_module.Forwardmask] = None,
202+
trapsig: bool = False,
203+
) -> None:
204+
"""Initialize LinemodeBuffer."""
205+
from .telopt import IP, AYT, BRK, EOF, IAC, SUSP, ABORT
206+
207+
self._buf: list[str] = []
208+
self.slctab = slctab
209+
self.forwardmask = forwardmask
210+
self.trapsig = trapsig
211+
self._trapsig_map: Dict[bytes, bytes] = {
212+
slc_module.SLC_IP: IAC + IP,
213+
slc_module.SLC_ABORT: IAC + ABORT,
214+
slc_module.SLC_SUSP: IAC + SUSP,
215+
slc_module.SLC_EOF: IAC + EOF,
216+
slc_module.SLC_BRK: IAC + BRK,
217+
slc_module.SLC_AYT: IAC + AYT,
218+
}
219+
220+
def _slc_val(self, func: bytes) -> Optional[int]:
221+
"""Return the active byte value for SLC function, or None if unsupported."""
222+
defn = self.slctab.get(func)
223+
if defn is None or defn.nosupport:
224+
return None
225+
v = defn.val
226+
return ord(v) if v and v != slc_module.theNULL else None
227+
228+
def feed(self, char: str) -> Tuple[str, Optional[bytes]]:
229+
"""
230+
Feed one character into the buffer.
231+
232+
:returns: ``(echo, data)`` where ``echo`` is text to display locally
233+
(may be empty) and ``data`` is bytes to send to server, or None
234+
if buffering.
235+
"""
236+
b = ord(char)
237+
if self.trapsig:
238+
for func, cmd in self._trapsig_map.items():
239+
if b == self._slc_val(func):
240+
return ("", cmd)
241+
if b == self._slc_val(slc_module.SLC_EC):
242+
if self._buf:
243+
self._buf.pop()
244+
return ("\b \b", None)
245+
return ("", None)
246+
if b == self._slc_val(slc_module.SLC_EL):
247+
n = len(self._buf)
248+
self._buf.clear()
249+
return ("\b \b" * n, None)
250+
if b == self._slc_val(slc_module.SLC_EW):
251+
popped = 0
252+
# skip trailing spaces (POSIX VWERASE behaviour)
253+
while self._buf and self._buf[-1] == " ":
254+
self._buf.pop()
255+
popped += 1
256+
while self._buf and self._buf[-1] != " ":
257+
self._buf.pop()
258+
popped += 1
259+
return ("\b \b" * popped, None)
260+
if char in ("\r", "\n"):
261+
line = "".join(self._buf) + char
262+
self._buf.clear()
263+
return (char, line.encode())
264+
if self.forwardmask is not None and b in self.forwardmask:
265+
data = ("".join(self._buf) + char).encode()
266+
self._buf.clear()
267+
return (char, data)
268+
self._buf.append(char)
269+
return (char, None)
270+
271+
183272
if sys.platform == "win32":
184273

185274
async def telnet_client_shell(
@@ -314,10 +403,7 @@ def _server_will_sga(self) -> bool:
314403
from .telopt import SGA
315404

316405
w = self.telnet_writer
317-
return bool(
318-
w.client
319-
and (w.remote_option.enabled(SGA) or w.local_option.enabled(SGA))
320-
)
406+
return bool(w.client and (w.remote_option.enabled(SGA) or w.local_option.enabled(SGA)))
321407

322408
def check_auto_mode(
323409
self, switched_to_raw: bool, last_will_echo: bool
@@ -334,6 +420,20 @@ def check_auto_mode(
334420
return None
335421
wecho = self.telnet_writer.will_echo
336422
wsga = self._server_will_sga()
423+
# LINEMODE EDIT: kernel must handle line editing; keep/restore cooked mode.
424+
# This takes priority over the SGA/ECHO raw-mode heuristics below.
425+
if (
426+
self.telnet_writer.local_option.enabled(LINEMODE)
427+
and self.telnet_writer.linemode.edit
428+
):
429+
if switched_to_raw:
430+
assert self._save_mode is not None
431+
self.set_mode(self._save_mode)
432+
self.telnet_writer.log.debug(
433+
"auto: LINEMODE EDIT confirmed, restoring cooked mode"
434+
)
435+
return (False, wecho, False)
436+
return None
337437
# WILL ECHO alone = line mode with server echo (suppress local echo)
338438
# WILL SGA (with or without ECHO) = raw/character-at-a-time
339439
should_go_raw = not switched_to_raw and wsga
@@ -366,20 +466,35 @@ def determine_mode(self, mode: "Terminal.ModeDef") -> "Terminal.ModeDef":
366466
367467
Auto mode (``_raw_mode is None``): follows the server's negotiation.
368468
369-
================= ======== ========== ================================
469+
================= ======== ========== ========================================
370470
Server negotiates ICANON ECHO Behavior
371-
================= ======== ========== ================================
471+
================= ======== ========== ========================================
372472
Nothing on on Line mode, local echo
473+
LINEMODE EDIT **on** on Cooked mode, kernel handles EC/EL/echo
474+
LINEMODE remote **off** **off** Raw, server echoes
373475
WILL SGA only **off** on Character-at-a-time, local echo
374476
WILL ECHO only on **off** Line mode, server echoes
375477
WILL SGA + ECHO **off** **off** Full kludge mode (most common)
376-
================= ======== ========== ================================
478+
================= ======== ========== ========================================
377479
"""
378480
raw_mode = _get_raw_mode(self.telnet_writer)
379481
will_echo = self.telnet_writer.will_echo
380482
will_sga = self._server_will_sga()
381483
# Auto mode (None): follow server negotiation
382484
if raw_mode is None:
485+
if self.telnet_writer.local_option.enabled(LINEMODE):
486+
linemode_mode = self.telnet_writer.linemode
487+
if linemode_mode.edit:
488+
# RFC 1184 / NetBSD reference: LINEMODE EDIT means ICANON on.
489+
# The kernel line discipline handles EC (VERASE), EL (VKILL),
490+
# EW (VWERASE), and echo. No software line editing needed.
491+
self.telnet_writer.log.debug(
492+
"auto: LINEMODE EDIT, cooked mode (kernel line editing)"
493+
)
494+
self.software_echo = False
495+
return mode # keep ICANON on; kernel handles EC/EL/EW and echo
496+
self.telnet_writer.log.debug("auto: LINEMODE remote, raw input server echo")
497+
return self._make_raw(mode, suppress_echo=True)
383498
if will_echo and will_sga:
384499
self.telnet_writer.log.debug("auto: server echo + SGA, kludge mode")
385500
return self._make_raw(mode)
@@ -535,6 +650,18 @@ def _ensure_autoreply_engine(
535650
"""Return the autoreply engine from the writer's context, if set."""
536651
return telnet_writer.ctx.autoreply_engine
537652

653+
def _get_linemode_buffer(writer: Union[TelnetWriter, TelnetWriterUnicode]) -> "LinemodeBuffer":
654+
"""Return (or lazily create) the LinemodeBuffer attached to *writer*."""
655+
buf: Optional[LinemodeBuffer] = getattr(writer, "_linemode_buf", None)
656+
if buf is None:
657+
buf = LinemodeBuffer(
658+
slctab=writer.slctab,
659+
forwardmask=writer.forwardmask,
660+
trapsig=writer.linemode.trapsig,
661+
)
662+
writer._linemode_buf = buf
663+
return buf
664+
538665
async def _raw_event_loop(
539666
telnet_reader: Union[TelnetReader, TelnetReaderUnicode],
540667
telnet_writer: Union[TelnetWriter, TelnetWriterUnicode],
@@ -593,7 +720,26 @@ async def _raw_event_loop(
593720
wait_for.remove(telnet_task)
594721
handle_close("Connection closed.")
595722
break
596-
new_timer, has_pending = _send_stdin(inp, telnet_writer, stdout, state.local_echo)
723+
linemode_edit = (
724+
telnet_writer.local_option.enabled(LINEMODE) and telnet_writer.linemode.edit
725+
)
726+
if linemode_edit and state.switched_to_raw:
727+
# Raw PTY or non-TTY: kernel not doing line editing, use LinemodeBuffer
728+
lmbuf = _get_linemode_buffer(telnet_writer)
729+
for ch in inp.decode(errors="replace"):
730+
echo, data = lmbuf.feed(ch)
731+
if echo:
732+
stdout.write(echo.encode())
733+
if data:
734+
telnet_writer._write(data)
735+
new_timer, has_pending = None, False
736+
elif linemode_edit:
737+
# Cooked PTY: kernel already handled EC/EL/echo; forward line directly
738+
new_timer, has_pending = _send_stdin(inp, telnet_writer, stdout, False)
739+
else:
740+
new_timer, has_pending = _send_stdin(
741+
inp, telnet_writer, stdout, state.local_echo
742+
)
597743
if has_pending and esc_timer_task not in wait_for:
598744
esc_timer_task = new_timer
599745
if esc_timer_task is not None:

telnetlib3/server.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,14 @@
4242
except ImportError:
4343
PTY_SUPPORT = False
4444

45-
__all__ = ("TelnetServer", "Server", "create_server", "run_server", "parse_server_args")
45+
__all__ = (
46+
"TelnetServer",
47+
"LinemodeServer",
48+
"Server",
49+
"create_server",
50+
"run_server",
51+
"parse_server_args",
52+
)
4653

4754

4855
class CONFIG(NamedTuple):
@@ -806,6 +813,42 @@ def connection_lost(self, exc: Optional[Exception]) -> None:
806813
_ = exc
807814

808815

816+
class LinemodeServer(TelnetServer):
817+
"""
818+
:class:`TelnetServer` subclass that negotiates LINEMODE EDIT.
819+
820+
In addition to the standard options negotiated by :class:`TelnetServer`,
821+
this server sends ``DO LINEMODE`` during advanced negotiation, proposes
822+
LINEMODE EDIT (local line editing by the client), and suppresses
823+
``WILL ECHO`` so the client performs local echoing via its LINEMODE buffer.
824+
825+
Use with :func:`create_server` to enable RFC 1184 LINEMODE EDIT on a
826+
:func:`~.telnet_server_shell` session or any custom shell.
827+
"""
828+
829+
from . import slc as _slc_module
830+
831+
#: Propose LINEMODE EDIT (local line editing) instead of remote mode.
832+
default_linemode = _slc_module.Linemode(_slc_module.LMODE_MODE_LOCAL)
833+
834+
def begin_advanced_negotiation(self) -> None:
835+
"""Negotiate standard options plus ``DO LINEMODE``."""
836+
from .telopt import DO, LINEMODE
837+
838+
super().begin_advanced_negotiation()
839+
# Propagate the protocol-level default_linemode to the writer so that
840+
# TelnetWriter.handle_will(LINEMODE) proposes the correct mode (LOCAL/EDIT)
841+
# rather than the TelnetWriter class default (REMOTE).
842+
self.writer.default_linemode = self.default_linemode
843+
self.writer.iac(DO, LINEMODE)
844+
845+
def _negotiate_echo(self) -> None:
846+
"""Skip ``WILL ECHO`` — LINEMODE EDIT client handles local echo."""
847+
if self._echo_negotiated:
848+
return
849+
self._echo_negotiated = True
850+
851+
809852
class Server:
810853
"""
811854
Telnet server that tracks connected clients.

0 commit comments

Comments
 (0)