Skip to content

Commit e72e5db

Browse files
committed
MCCP2/3 --compression or --no-compression
1 parent 395e1eb commit e72e5db

11 files changed

Lines changed: 1060 additions & 7 deletions

File tree

README.rst

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,32 @@ If GA causes unwanted output for your use case, disable it::
127127
For PTY shells, GA is sent after 500ms of output idle time to avoid
128128
injecting GA in the middle of streaming output.
129129

130+
Compression (MCCP)
131+
~~~~~~~~~~~~~~~~~~
132+
133+
MCCP2 (server-to-client) and MCCP3 (client-to-server) zlib compression are
134+
supported, widely used by MUD servers to reduce bandwidth::
135+
136+
# connect to a MUD that offers MCCP compression
137+
telnetlib3-client dunemud.net 6789
138+
139+
# or with TLS (compression auto-disabled over TLS, CRIME/BREACH mitigation)
140+
telnetlib3-client --ssl dunemud.net 6788
141+
142+
# actively request compression from a server
143+
telnetlib3-client --compression dunemud.net 6789
144+
145+
# reject compression even if the server offers it
146+
telnetlib3-client --no-compression dunemud.net 6789
147+
148+
# host a MUD server that advertises MCCP2/MCCP3
149+
telnetlib3-server --compression --shell=my_mud.shell
150+
151+
By default (without ``--compression`` or ``--no-compression``), the client
152+
passively accepts compression when offered by the server, and the server does
153+
not advertise compression. Compression is automatically disabled over TLS
154+
connections to avoid CRIME/BREACH attacks.
155+
130156

131157
Asyncio Protocol
132158
----------------

docs/rfcs.rst

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,16 +81,23 @@ Dungeon) servers and clients.
8181
* `MSSP`_ (MUD Server Status Protocol, option 70). Server metadata protocol
8282
for MUD crawlers and directories, providing server name, player count,
8383
codebase, and other listing information.
84+
* `MCCP2`_ (MUD Client Compression Protocol v2, option 86). Server-to-client
85+
zlib compression, reducing bandwidth for output-heavy sessions. Activated
86+
via ``IAC SB MCCP2 IAC SE``; all subsequent server output is compressed.
87+
* `MCCP3`_ (MUD Client Compression Protocol v3, option 87). Client-to-server
88+
zlib compression, the reverse direction of MCCP2.
8489

8590
.. _GMCP: https://www.gammon.com.au/gmcp
8691
.. _MSDP: https://tintin.mudhalla.net/protocols/msdp/
8792
.. _MSSP: https://tintin.mudhalla.net/protocols/mssp/
93+
.. _MCCP2: https://tintin.mudhalla.net/protocols/mccp/
94+
.. _MCCP3: https://tintin.mudhalla.net/protocols/mccp/
8895

8996
MUDs Not Implemented
9097
--------------------
9198

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

96103
Additional Resources

telnetlib3/_base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ def _process_data_chunk(
4545
or ``None`` when only IAC (255) is special.
4646
:param log_fn: Callable for logging exceptions (e.g. ``logger.warning``).
4747
:returns: ``True`` if any IAC/SB command was observed.
48+
49+
When MCCP2 is activated mid-chunk, the remaining compressed bytes are
50+
stored in ``writer._compressed_remainder`` for the caller to consume.
4851
"""
4952
cmd_received = False
5053
n = len(data)
@@ -84,6 +87,12 @@ def _process_data_chunk(
8487
out_start = i
8588
feeding_oob = bool(writer.is_oob)
8689

90+
if writer._mccp2_activated:
91+
writer._mccp2_activated = False
92+
writer.mccp2_active = True
93+
writer._compressed_remainder = data[i:] if i < n else b""
94+
return True
95+
8796
return cmd_received
8897

8998

telnetlib3/client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,15 @@ def __init__(
6565
force_binary: bool = False,
6666
connect_minwait: float = 0,
6767
connect_maxwait: float = 4.0,
68+
compression: Optional[bool] = None,
6869
limit: Optional[int] = None,
6970
waiter_closed: Optional[asyncio.Future[None]] = None,
7071
_waiter_connected: Optional[asyncio.Future[None]] = None,
7172
gmcp_modules: Optional[List[str]] = None,
7273
gmcp_log: bool = False,
7374
) -> None:
7475
"""Initialize TelnetClient with terminal parameters."""
76+
self._compression = compression
7577
super().__init__(
7678
shell=shell,
7779
encoding=encoding,
@@ -118,6 +120,9 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None:
118120

119121
super().connection_made(transport)
120122

123+
# Set compression policy on writer
124+
self.writer.compression = self._compression
125+
121126
# Wire extended rfc callbacks for requests of
122127
# terminal attributes, environment values, etc.
123128
for opt, func in (
@@ -472,6 +477,7 @@ async def open_connection(
472477
connect_minwait: float = 0,
473478
connect_maxwait: float = 3.0,
474479
connect_timeout: Optional[float] = None,
480+
compression: Optional[bool] = None,
475481
waiter_closed: Optional[asyncio.Future[None]] = None,
476482
_waiter_connected: Optional[asyncio.Future[None]] = None,
477483
limit: Optional[int] = None,
@@ -531,6 +537,9 @@ async def open_connection(
531537
connection attempt may block indefinitely. When specified, a
532538
:exc:`ConnectionError` is raised if the connection is not established
533539
within the given time.
540+
:param compression: MCCP compression policy. ``None`` (default) passively
541+
accepts compression when offered by the server. ``True`` actively
542+
requests MCCP2/MCCP3. ``False`` rejects all compression offers.
534543
535544
:param force_binary: When ``True``, the encoding is used regardless
536545
of BINARY mode negotiation.
@@ -565,6 +574,7 @@ def connection_factory() -> client_base.BaseClient:
565574
shell=shell,
566575
connect_minwait=connect_minwait,
567576
connect_maxwait=connect_maxwait,
577+
compression=compression,
568578
waiter_closed=waiter_closed,
569579
_waiter_connected=_waiter_connected,
570580
limit=limit,
@@ -897,6 +907,13 @@ def _get_argument_parser() -> argparse.ArgumentParser:
897907
"keys instead of encoding-specific control codes. Use for "
898908
"BBSes that expect ANSI cursor sequences.",
899909
)
910+
parser.add_argument(
911+
"--compression",
912+
action=argparse.BooleanOptionalAction,
913+
default=None,
914+
help="MCCP compression: --compression to request, --no-compression to reject, "
915+
"omit to passively accept (default)",
916+
)
900917
parser.add_argument(
901918
"--ssl", action="store_true", default=False, help="connect using TLS (TELNETS)"
902919
)
@@ -1029,6 +1046,7 @@ def _transform_args(args: argparse.Namespace) -> Dict[str, Any]:
10291046
if args.gmcp_modules
10301047
else None
10311048
),
1049+
"compression": args.compression,
10321050
"gmcp_log": args.gmcp_log,
10331051
"typescript": args.typescript,
10341052
}

telnetlib3/client_base.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
# std imports
6+
import zlib
67
import asyncio
78
import logging
89
import weakref
@@ -66,6 +67,12 @@ def __init__(
6667
self.writer: Optional[Union[TelnetWriter, TelnetWriterUnicode]] = None
6768
self._limit = limit
6869

70+
# MCCP2: server→client decompression
71+
self._mccp2_decompressor: Optional[zlib.Decompress] = None
72+
# MCCP3: client→server compression
73+
self._mccp3_compressor: Optional[zlib.Compress] = None
74+
self._mccp3_orig_write: Any = None
75+
6976
# High-throughput receive pipeline
7077
self._rx_queue: collections.deque[bytes] = collections.deque()
7178
self._rx_bytes = 0
@@ -93,6 +100,11 @@ def connection_lost(self, exc: Optional[Exception]) -> None:
93100
return
94101
self._closing = True
95102

103+
# Clean up MCCP compressors/decompressors
104+
self._mccp2_decompressor = None
105+
self._mccp3_compressor = None
106+
self._mccp3_orig_write = None
107+
96108
# Drain any pending rx data before signalling EOF to prevent
97109
# _process_rx from calling feed_data() after feed_eof().
98110
self._rx_queue.clear()
@@ -343,6 +355,26 @@ def _process_chunk(self, data: bytes) -> bool:
343355
"""Process a chunk of received bytes; return True if any IAC/SB cmd observed."""
344356
self._last_received = datetime.datetime.now()
345357

358+
# MCCP2: decompress server→client data when active
359+
if self._mccp2_decompressor is not None:
360+
try:
361+
data = self._mccp2_decompressor.decompress(data)
362+
except zlib.error:
363+
self.log.warning("MCCP2 decompression error, disabling")
364+
self._mccp2_end()
365+
return False
366+
if self._mccp2_decompressor.eof:
367+
unused = self._mccp2_decompressor.unused_data
368+
self._mccp2_end()
369+
cmd = self._process_chunk_inner(data)
370+
if unused:
371+
cmd = self._process_chunk(unused) or cmd
372+
return cmd
373+
374+
return self._process_chunk_inner(data)
375+
376+
def _process_chunk_inner(self, data: bytes) -> bool:
377+
"""Inner chunk processing with IAC interpretation and mid-chunk MCCP2 detection."""
346378
try:
347379
mode = self.writer.mode
348380
except Exception:
@@ -355,7 +387,22 @@ def _process_chunk(self, data: bytes) -> bool:
355387
else:
356388
slc_special = None
357389

358-
return _process_data_chunk(data, self.writer, self.reader, slc_special, self.log.warning)
390+
cmd_received = _process_data_chunk(
391+
data, self.writer, self.reader, slc_special, self.log.warning
392+
)
393+
394+
if self.writer._compressed_remainder is not None:
395+
remainder = self.writer._compressed_remainder
396+
self.writer._compressed_remainder = None
397+
self._mccp2_start()
398+
if remainder:
399+
cmd_received = self._process_chunk(remainder) or cmd_received
400+
401+
# MCCP3: start compressor when writer signals activation
402+
if self.writer.mccp3_active and self._mccp3_compressor is None:
403+
self._mccp3_start()
404+
405+
return cmd_received
359406

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

445+
def _mccp2_start(self) -> None:
446+
"""Start MCCP2 decompression of server→client data."""
447+
self._mccp2_decompressor = zlib.decompressobj()
448+
self.log.debug("MCCP2 decompression started (server→client)")
449+
450+
def _mccp2_end(self) -> None:
451+
"""Stop MCCP2 decompression."""
452+
self._mccp2_decompressor = None
453+
self.writer.mccp2_active = False
454+
self.log.debug("MCCP2 decompression ended (server→client)")
455+
456+
def _mccp3_start(self) -> None:
457+
"""Start MCCP3 compression of client→server data."""
458+
self._mccp3_compressor = zlib.compressobj(
459+
zlib.Z_BEST_COMPRESSION, zlib.DEFLATED, 12, 5, zlib.Z_DEFAULT_STRATEGY
460+
)
461+
# Wrap transport.write so all outbound bytes are compressed
462+
transport = self.writer._transport
463+
orig_write = transport.write
464+
465+
def compressed_write(data: bytes) -> None:
466+
if self._mccp3_compressor is not None:
467+
compressed = self._mccp3_compressor.compress(data)
468+
compressed += self._mccp3_compressor.flush(zlib.Z_SYNC_FLUSH)
469+
orig_write(compressed)
470+
else:
471+
orig_write(data)
472+
473+
transport.write = compressed_write # type: ignore[assignment]
474+
self._mccp3_orig_write = orig_write
475+
self.log.debug("MCCP3 compression started (client→server)")
476+
477+
def _mccp3_end(self) -> None:
478+
"""Stop MCCP3 compression, flush Z_FINISH."""
479+
if self._mccp3_compressor is not None:
480+
if not self.writer.is_closing():
481+
self._mccp3_orig_write(
482+
self._mccp3_compressor.flush(zlib.Z_FINISH)
483+
)
484+
self._mccp3_compressor = None
485+
# Restore original transport.write
486+
self.writer._transport.write = self._mccp3_orig_write # type: ignore[method-assign]
487+
self.writer.mccp3_active = False
488+
self.log.debug("MCCP3 compression ended (client→server)")
489+
398490
def _check_negotiation_timer(self) -> None:
399491
self._check_later.cancel()
400492
self._tasks.remove(self._check_later)

telnetlib3/fingerprinting.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@
8383
SUPDUPOUTPUT,
8484
VT3270REGIME,
8585
AUTHENTICATION,
86+
MCCP2_COMPRESS,
87+
MCCP3_COMPRESS,
8688
COM_PORT_OPTION,
8789
PRAGMA_HEARTBEAT,
8890
SUPPRESS_LOCAL_ECHO,
@@ -260,6 +262,8 @@ class FingerprintingServer(FingerprintingTelnetServer, TelnetServer):
260262
# returning a hard error for anything else. GMCP-capable MUD clients
261263
# typically self-announce via IAC WILL GMCP, so probing is unnecessary.
262264
EXTENDED_OPTIONS = [
265+
(MCCP2_COMPRESS, "MCCP2", "MUD Client Compression Protocol v2"),
266+
(MCCP3_COMPRESS, "MCCP3", "MUD Client Compression Protocol v3"),
263267
(GMCP, "GMCP", "Generic MUD Communication Protocol"),
264268
(MSDP, "MSDP", "MUD Server Data Protocol"),
265269
(MSSP, "MSSP", "MUD Server Status Protocol"),

0 commit comments

Comments
 (0)