Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ The CLI utility ``telnetlib3-client`` is provided for connecting to servers and
``telnetlib3-server`` for hosting a server.

Both tools accept the argument ``--shell=my_module.fn_shell`` describing a python module path to a
function of signature ``async def shell(reader, writer)``. The server also provides ``--pty-exec``
argument to host stand-alone programs.
function of signature ``async def shell(reader, writer)``. The server also provides a
``--pty-exec`` argument allowing it to act as a telnet server for any CLI/TUI programs.

::

Expand All @@ -59,7 +59,7 @@ argument to host stand-alone programs.
# automatic script communicates with a server
telnetlib3-client --shell bin.client_wargame.shell 1984.ws 666

# run a server bound with the default shell bound to 127.0.0.1 6023
# run a default shell server bound to 127.0.0.1 6023
telnetlib3-server

# or custom ip, port and shell
Expand Down Expand Up @@ -112,6 +112,48 @@ connected to a TCP socket without any telnet negotiation may require "raw" mode

telnetlib3-client --raw-mode area52.tk 5200 --encoding=atascii

Go-Ahead (GA)
~~~~~~~~~~~~~

When a client does not negotiate Suppress Go-Ahead (SGA), the server sends
``IAC GA`` after output to signal that the client may transmit. This is
correct behavior for MUD clients like Mudlet that expect prompt detection
via GA.

If GA causes unwanted output for your use case, disable it::

telnetlib3-server --never-send-ga

For PTY shells, GA is sent after 500ms of output idle time to avoid
injecting GA in the middle of streaming output.

Compression (MCCP)
~~~~~~~~~~~~~~~~~~

MCCP2 (server-to-client) and MCCP3 (client-to-server) zlib compression are
supported, widely used by MUD servers to reduce bandwidth::

# connect to a MUD that offers MCCP compression
telnetlib3-client dunemud.net 6789

# or with TLS (compression auto-disabled over TLS, CRIME/BREACH mitigation)
telnetlib3-client --ssl dunemud.net 6788

# actively request compression from a server
telnetlib3-client --compression dunemud.net 6789

# reject compression even if the server offers it
telnetlib3-client --no-compression dunemud.net 6789

# host a MUD server that advertises MCCP2/MCCP3
telnetlib3-server --compression --shell=my_mud.shell

By default (without ``--compression`` or ``--no-compression``), the client
passively accepts compression when offered by the server, and the server does
not advertise compression. Compression is automatically disabled over TLS
connections to avoid CRIME/BREACH attacks.


Asyncio Protocol
----------------

Expand Down Expand Up @@ -139,8 +181,10 @@ To migrate code, change import statements:
# NEW imports:
import telnetlib3

This library *also* provides an additional client (and server) API through a similar interface but
offering more advanced negotiation features and options. See `sync API documentation`_ for more.
``telnetlib3`` did not provide server support, while this library also provides
both client and server support through a similar Blocking API interface.

See `sync API documentation`_ for details.

Quick Example
=============
Expand Down
4 changes: 4 additions & 0 deletions docs/history.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
History
=======
3.0.1 (unreleased)
* new: MCCP2 and MCCP3. Both client and server ends passively support if requested, and request
support by --compression or deny support by --no-compression.

3.0.0
* change: :attr:`~telnetlib3.client_base.BaseClient.connect_minwait` default
now 0 (was 1.0 seconds in library API).
Expand Down
9 changes: 8 additions & 1 deletion docs/rfcs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -81,16 +81,23 @@ Dungeon) servers and clients.
* `MSSP`_ (MUD Server Status Protocol, option 70). Server metadata protocol
for MUD crawlers and directories, providing server name, player count,
codebase, and other listing information.
* `MCCP2`_ (MUD Client Compression Protocol v2, option 86). Server-to-client
zlib compression, reducing bandwidth for output-heavy sessions. Activated
via ``IAC SB MCCP2 IAC SE``; all subsequent server output is compressed.
* `MCCP3`_ (MUD Client Compression Protocol v3, option 87). Client-to-server
zlib compression, the reverse direction of MCCP2.

.. _GMCP: https://www.gammon.com.au/gmcp
.. _MSDP: https://tintin.mudhalla.net/protocols/msdp/
.. _MSSP: https://tintin.mudhalla.net/protocols/mssp/
.. _MCCP2: https://tintin.mudhalla.net/protocols/mccp/
.. _MCCP3: https://tintin.mudhalla.net/protocols/mccp/

MUDs Not Implemented
--------------------

Constants are also defined for the following MUD options, though their handlers
are not implemented: MCCP/MCCP2 (85/86, compression), MXP (91, markup), ZMP
are not implemented: MCCP (85, legacy compression), MXP (91, markup), ZMP
(93, messaging), MSP (90, sound), and ATCP (200, Achaea-specific).

Additional Resources
Expand Down
9 changes: 9 additions & 0 deletions telnetlib3/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ def _process_data_chunk(
or ``None`` when only IAC (255) is special.
:param log_fn: Callable for logging exceptions (e.g. ``logger.warning``).
:returns: ``True`` if any IAC/SB command was observed.

When MCCP2 is activated mid-chunk, the remaining compressed bytes are
stored in ``writer._compressed_remainder`` for the caller to consume.
"""
cmd_received = False
n = len(data)
Expand Down Expand Up @@ -84,6 +87,12 @@ def _process_data_chunk(
out_start = i
feeding_oob = bool(writer.is_oob)

if writer._mccp2_activated:
writer._mccp2_activated = False
writer.mccp2_active = True
writer._compressed_remainder = data[i:] if i < n else b""
return True

return cmd_received


Expand Down
18 changes: 18 additions & 0 deletions telnetlib3/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,15 @@ def __init__(
force_binary: bool = False,
connect_minwait: float = 0,
connect_maxwait: float = 4.0,
compression: Optional[bool] = None,
limit: Optional[int] = None,
waiter_closed: Optional[asyncio.Future[None]] = None,
_waiter_connected: Optional[asyncio.Future[None]] = None,
gmcp_modules: Optional[List[str]] = None,
gmcp_log: bool = False,
) -> None:
"""Initialize TelnetClient with terminal parameters."""
self._compression = compression
super().__init__(
shell=shell,
encoding=encoding,
Expand Down Expand Up @@ -118,6 +120,9 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None:

super().connection_made(transport)

# Set compression policy on writer
self.writer.compression = self._compression

# Wire extended rfc callbacks for requests of
# terminal attributes, environment values, etc.
for opt, func in (
Expand Down Expand Up @@ -472,6 +477,7 @@ async def open_connection(
connect_minwait: float = 0,
connect_maxwait: float = 3.0,
connect_timeout: Optional[float] = None,
compression: Optional[bool] = None,
waiter_closed: Optional[asyncio.Future[None]] = None,
_waiter_connected: Optional[asyncio.Future[None]] = None,
limit: Optional[int] = None,
Expand Down Expand Up @@ -531,6 +537,9 @@ async def open_connection(
connection attempt may block indefinitely. When specified, a
:exc:`ConnectionError` is raised if the connection is not established
within the given time.
:param compression: MCCP compression policy. ``None`` (default) passively
accepts compression when offered by the server. ``True`` actively
requests MCCP2/MCCP3. ``False`` rejects all compression offers.

:param force_binary: When ``True``, the encoding is used regardless
of BINARY mode negotiation.
Expand Down Expand Up @@ -565,6 +574,7 @@ def connection_factory() -> client_base.BaseClient:
shell=shell,
connect_minwait=connect_minwait,
connect_maxwait=connect_maxwait,
compression=compression,
waiter_closed=waiter_closed,
_waiter_connected=_waiter_connected,
limit=limit,
Expand Down Expand Up @@ -897,6 +907,13 @@ def _get_argument_parser() -> argparse.ArgumentParser:
"keys instead of encoding-specific control codes. Use for "
"BBSes that expect ANSI cursor sequences.",
)
parser.add_argument(
"--compression",
action=argparse.BooleanOptionalAction,
default=None,
help="MCCP compression: --compression to request, --no-compression to reject, "
"omit to passively accept (default)",
)
parser.add_argument(
"--ssl", action="store_true", default=False, help="connect using TLS (TELNETS)"
)
Expand Down Expand Up @@ -1029,6 +1046,7 @@ def _transform_args(args: argparse.Namespace) -> Dict[str, Any]:
if args.gmcp_modules
else None
),
"compression": args.compression,
"gmcp_log": args.gmcp_log,
"typescript": args.typescript,
}
Expand Down
94 changes: 93 additions & 1 deletion telnetlib3/client_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

# std imports
import zlib
import asyncio
import logging
import weakref
Expand Down Expand Up @@ -66,6 +67,12 @@ def __init__(
self.writer: Optional[Union[TelnetWriter, TelnetWriterUnicode]] = None
self._limit = limit

# MCCP2: server→client decompression
self._mccp2_decompressor: Optional[zlib.Decompress] = None
# MCCP3: client→server compression
self._mccp3_compressor: Optional[zlib.Compress] = None
self._mccp3_orig_write: Any = None

# High-throughput receive pipeline
self._rx_queue: collections.deque[bytes] = collections.deque()
self._rx_bytes = 0
Expand Down Expand Up @@ -93,6 +100,11 @@ def connection_lost(self, exc: Optional[Exception]) -> None:
return
self._closing = True

# Clean up MCCP compressors/decompressors
self._mccp2_decompressor = None
self._mccp3_compressor = None
self._mccp3_orig_write = None

# Drain any pending rx data before signalling EOF to prevent
# _process_rx from calling feed_data() after feed_eof().
self._rx_queue.clear()
Expand Down Expand Up @@ -343,6 +355,26 @@ def _process_chunk(self, data: bytes) -> bool:
"""Process a chunk of received bytes; return True if any IAC/SB cmd observed."""
self._last_received = datetime.datetime.now()

# MCCP2: decompress server→client data when active
if self._mccp2_decompressor is not None:
try:
data = self._mccp2_decompressor.decompress(data)
except zlib.error:
self.log.warning("MCCP2 decompression error, disabling")
self._mccp2_end()
return False
if self._mccp2_decompressor.eof:
unused = self._mccp2_decompressor.unused_data
self._mccp2_end()
cmd = self._process_chunk_inner(data)
if unused:
cmd = self._process_chunk(unused) or cmd
return cmd

return self._process_chunk_inner(data)

def _process_chunk_inner(self, data: bytes) -> bool:
"""Inner chunk processing with IAC interpretation and mid-chunk MCCP2 detection."""
try:
mode = self.writer.mode
except Exception:
Expand All @@ -355,7 +387,22 @@ def _process_chunk(self, data: bytes) -> bool:
else:
slc_special = None

return _process_data_chunk(data, self.writer, self.reader, slc_special, self.log.warning)
cmd_received = _process_data_chunk(
data, self.writer, self.reader, slc_special, self.log.warning
)

if self.writer._compressed_remainder is not None:
remainder = self.writer._compressed_remainder
self.writer._compressed_remainder = None
self._mccp2_start()
if remainder:
cmd_received = self._process_chunk(remainder) or cmd_received

# MCCP3: start compressor when writer signals activation
if self.writer.mccp3_active and self._mccp3_compressor is None:
self._mccp3_start()

return cmd_received

async def _process_rx(self) -> None:
"""Async processor for receive queue that yields control and applies backpressure."""
Expand Down Expand Up @@ -395,6 +442,51 @@ async def _process_rx(self) -> None:
if any_cmd and not self._waiter_connected.done():
self._check_negotiation_timer()

def _mccp2_start(self) -> None:
"""Start MCCP2 decompression of server→client data."""
self._mccp2_decompressor = zlib.decompressobj()
self.log.debug("MCCP2 decompression started (server→client)")

def _mccp2_end(self) -> None:
"""Stop MCCP2 decompression."""
self._mccp2_decompressor = None
self.writer.mccp2_active = False
self.log.debug("MCCP2 decompression ended (server→client)")

def _mccp3_start(self) -> None:
"""Start MCCP3 compression of client→server data."""
self._mccp3_compressor = zlib.compressobj(
zlib.Z_BEST_COMPRESSION, zlib.DEFLATED, 12, 5, zlib.Z_DEFAULT_STRATEGY
)
# Wrap transport.write so all outbound bytes are compressed
transport = self.writer._transport
orig_write = transport.write

def compressed_write(data: bytes) -> None:
if self._mccp3_compressor is not None:
compressed = self._mccp3_compressor.compress(data)
compressed += self._mccp3_compressor.flush(zlib.Z_SYNC_FLUSH)
orig_write(compressed)
else:
orig_write(data)

transport.write = compressed_write # type: ignore[assignment]
self._mccp3_orig_write = orig_write
self.log.debug("MCCP3 compression started (client→server)")

def _mccp3_end(self) -> None:
"""Stop MCCP3 compression, flush Z_FINISH."""
if self._mccp3_compressor is not None:
if not self.writer.is_closing():
self._mccp3_orig_write(
self._mccp3_compressor.flush(zlib.Z_FINISH)
)
self._mccp3_compressor = None
# Restore original transport.write
self.writer._transport.write = self._mccp3_orig_write # type: ignore[method-assign]
self.writer.mccp3_active = False
self.log.debug("MCCP3 compression ended (client→server)")

def _check_negotiation_timer(self) -> None:
self._check_later.cancel()
self._tasks.remove(self._check_later)
Expand Down
4 changes: 4 additions & 0 deletions telnetlib3/fingerprinting.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
SUPDUPOUTPUT,
VT3270REGIME,
AUTHENTICATION,
MCCP2_COMPRESS,
MCCP3_COMPRESS,
COM_PORT_OPTION,
PRAGMA_HEARTBEAT,
SUPPRESS_LOCAL_ECHO,
Expand Down Expand Up @@ -260,6 +262,8 @@ class FingerprintingServer(FingerprintingTelnetServer, TelnetServer):
# returning a hard error for anything else. GMCP-capable MUD clients
# typically self-announce via IAC WILL GMCP, so probing is unnecessary.
EXTENDED_OPTIONS = [
(MCCP2_COMPRESS, "MCCP2", "MUD Client Compression Protocol v2"),
(MCCP3_COMPRESS, "MCCP3", "MUD Client Compression Protocol v3"),
(GMCP, "GMCP", "Generic MUD Communication Protocol"),
(MSDP, "MSDP", "MUD Server Data Protocol"),
(MSSP, "MSSP", "MUD Server Status Protocol"),
Expand Down
Loading
Loading