Skip to content

Commit 4bc79f7

Browse files
authored
Bugfixes and Enhancements from mud fingerprinting (jquast#119)
* enhancement: reversed ``WILL``/``DO`` for directional options (e.g. ``WILL NAWS`` from server, ``DO TTYPE`` from client) now gracefully refused with ``DONT``/``WONT`` instead of raising ``ValueError``. * bugfix: ``LINEMODE DO FORWARDMASK`` subnegotiation no longer raises ``NotImplementedError``; the mask is accepted and logged at debug level. * enhancement: ``NEW_ENVIRON SEND`` and response logging improved -- ``SEND (all)`` / ``env send: (empty)`` instead of raw byte dumps. * enhancement: ``telnetlib3-fingerprint`` now probes MSDP and MSSP options and captures MSSP server status data in session output. * bugfix: server incorrectly accepted ``DO TSPEED`` and ``DO SNDLOC`` with ``WILL`` responses. These are client-only options per :rfc:`1079` and :rfc:`779`; the server now correctly rejects them. * bugfix: GMCP, MSDP, and MSSP decoding now uses ``--encoding`` when set, falling back to latin-1 for non-UTF-8 bytes instead of lossy replacement.
1 parent c156cc2 commit 4bc79f7

10 files changed

Lines changed: 395 additions & 54 deletions

docs/history.rst

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
History
22
=======
33
2.3.0 *unreleased*
4-
* bugfix: missing LICENSE.txt in sdist file.
4+
* bugfix: repeat "socket.send() raised exception." exceptions
55
* bugfix: server incorrectly accepted ``DO TSPEED`` and ``DO SNDLOC``
66
with ``WILL`` responses. These are client-only options per :rfc:`1079`
77
and :rfc:`779`; the server now correctly rejects them.
8-
* bugfix: repeat "socket.send() raised exception." exceptions
8+
* bugfix: ``LINEMODE DO FORWARDMASK`` subnegotiation no longer raises
9+
``NotImplementedError``; the mask is accepted (logged only).
910
* bugfix: echo doubling in ``--pty-exec`` without ``--pty-raw`` (linemode).
11+
* bugfix: missing LICENSE.txt in sdist file.
1012
* new: :mod:`telnetlib3.mud` module with encode/decode functions for
1113
GMCP (option 201), MSDP (option 69), and MSSP (option 70) MUD telnet
1214
protocols.
@@ -22,6 +24,15 @@ History
2224
for fingerprinting of connected clients.
2325
* new: ``telnetlib3-fingerprint`` CLI for fingerprinting the given remote
2426
server, probing telnet option support and capturing banners.
27+
* enhancement: reversed ``WILL``/``DO`` for directional options (e.g. ``WILL
28+
NAWS`` from server, ``DO TTYPE`` from client) now gracefully refused with
29+
``DONT``/``WONT`` instead of raising ``ValueError``.
30+
* enhancement: ``NEW_ENVIRON SEND`` and response logging improved --
31+
``SEND (all)`` / ``env send: (empty)`` instead of raw byte dumps.
32+
* bugfix: GMCP, MSDP, and MSSP decoding now uses ``--encoding`` when set,
33+
falling back to latin-1 for non-UTF-8 bytes instead of lossy replacement.
34+
* enhancement: ``telnetlib3-fingerprint`` now probes MSDP and MSSP options
35+
and captures MSSP server status data in session output.
2536

2637
2.2.0
2738
* bugfix: workaround for Microsoft Telnet client crash on

telnetlib3/fingerprinting.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
ECHO,
3737
GMCP,
3838
MSDP,
39+
MSSP,
3940
NAMS,
4041
NAOL,
4142
NAOP,
@@ -249,7 +250,11 @@ class FingerprintingServer(FingerprintingTelnetServer, TelnetServer):
249250
# icy_term (icy_net) only accepts option bytes 0-49, 138-140, and 255,
250251
# returning a hard error for anything else. GMCP-capable MUD clients
251252
# typically self-announce via IAC WILL GMCP, so probing is unnecessary.
252-
EXTENDED_OPTIONS = [(GMCP, "GMCP", "Generic MUD Communication Protocol")]
253+
EXTENDED_OPTIONS = [
254+
(GMCP, "GMCP", "Generic MUD Communication Protocol"),
255+
(MSDP, "MSDP", "MUD Server Data Protocol"),
256+
(MSSP, "MSSP", "MUD Server Status Protocol"),
257+
]
253258

254259
LEGACY_OPTIONS = [
255260
(AUTHENTICATION, "AUTHENTICATION", "Telnet authentication"),

telnetlib3/mud.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@
4040
)
4141

4242

43+
def _decode_best_effort(buf: bytes, encoding: str = "utf-8") -> str:
44+
"""
45+
Decode bytes trying *encoding* first, falling back to latin-1.
46+
47+
:param buf: Raw bytes to decode.
48+
:param encoding: Primary encoding to attempt.
49+
:returns: Decoded string.
50+
"""
51+
try:
52+
return buf.decode(encoding)
53+
except (UnicodeDecodeError, LookupError):
54+
return buf.decode("latin-1")
55+
56+
4357
def gmcp_encode(package: str, data: Any = None) -> bytes:
4458
"""
4559
Encode a GMCP message.
@@ -53,21 +67,22 @@ def gmcp_encode(package: str, data: Any = None) -> bytes:
5367
return package.encode("utf-8") + b" " + json.dumps(data, separators=(",", ":")).encode("utf-8")
5468

5569

56-
def gmcp_decode(buf: bytes) -> tuple[str, Any]:
70+
def gmcp_decode(buf: bytes, encoding: str = "utf-8") -> tuple[str, Any]:
5771
"""
5872
Decode a GMCP payload.
5973
6074
:param buf: GMCP payload bytes
75+
:param encoding: Character encoding to try first, falls back to latin-1.
6176
:returns: Tuple of (package, data), where data is None if no JSON present
6277
:raises ValueError: If JSON is malformed
6378
"""
6479
parts = buf.split(b" ", 1)
6580
if len(parts) == 1:
66-
return (buf.decode("utf-8"), None)
81+
return (_decode_best_effort(buf, encoding), None)
6782

68-
package = parts[0].decode("utf-8")
83+
package = _decode_best_effort(parts[0], encoding)
6984
try:
70-
data = json.loads(parts[1].decode("utf-8"))
85+
data = json.loads(_decode_best_effort(parts[1], encoding))
7186
except json.JSONDecodeError as exc:
7287
raise ValueError(f"Invalid JSON in GMCP payload: {exc}") from exc
7388
return (package, data)
@@ -108,18 +123,19 @@ class MsdpParser:
108123

109124
_DELIMITERS = (MSDP_VAR, MSDP_VAL, MSDP_TABLE_CLOSE, MSDP_ARRAY_CLOSE)
110125

111-
def __init__(self, buf: bytes) -> None:
126+
def __init__(self, buf: bytes, encoding: str = "utf-8") -> None:
112127
"""Initialize parser with raw MSDP buffer."""
113128
self.buf = buf
114129
self.idx = 0
130+
self.encoding = encoding
115131

116132
def _read_string(self) -> str:
117133
start = self.idx
118134
while (
119135
self.idx < len(self.buf) and self.buf[self.idx : self.idx + 1] not in self._DELIMITERS
120136
):
121137
self.idx += 1
122-
return self.buf[start : self.idx].decode("utf-8")
138+
return _decode_best_effort(self.buf[start : self.idx], self.encoding)
123139

124140
def _read_key(self) -> str:
125141
start = self.idx
@@ -128,7 +144,7 @@ def _read_key(self) -> str:
128144
MSDP_VAR,
129145
):
130146
self.idx += 1
131-
return self.buf[start : self.idx].decode("utf-8")
147+
return _decode_best_effort(self.buf[start : self.idx], self.encoding)
132148

133149
def _parse_table(self) -> dict[str, Any]:
134150
table: dict[str, Any] = {}
@@ -181,14 +197,15 @@ def parse(self) -> dict[str, Any]:
181197
return result
182198

183199

184-
def msdp_decode(buf: bytes) -> dict[str, Any]:
200+
def msdp_decode(buf: bytes, encoding: str = "utf-8") -> dict[str, Any]:
185201
"""
186202
Decode MSDP wire bytes to dictionary.
187203
188204
:param buf: MSDP payload bytes
205+
:param encoding: Character encoding to try first, falls back to latin-1.
189206
:returns: Dictionary of variable names to values
190207
"""
191-
return MsdpParser(buf).parse()
208+
return MsdpParser(buf, encoding=encoding).parse()
192209

193210

194211
def mssp_encode(variables: dict[str, str | list[str]]) -> bytes:
@@ -209,11 +226,12 @@ def mssp_encode(variables: dict[str, str | list[str]]) -> bytes:
209226
return result
210227

211228

212-
def mssp_decode(buf: bytes) -> dict[str, str | list[str]]:
229+
def mssp_decode(buf: bytes, encoding: str = "utf-8") -> dict[str, str | list[str]]:
213230
"""
214231
Decode MSSP wire bytes to dictionary.
215232
216233
:param buf: MSSP payload bytes
234+
:param encoding: Character encoding to try first, falls back to latin-1.
217235
:returns: Dictionary with str values for single entries, list[str] for multiple
218236
"""
219237
result: dict[str, str | list[str]] = {}
@@ -226,13 +244,13 @@ def mssp_decode(buf: bytes) -> dict[str, str | list[str]]:
226244
var_start = idx
227245
while idx < len(buf) and buf[idx : idx + 1] not in (MSSP_VAL, MSSP_VAR):
228246
idx += 1
229-
current_var = buf[var_start:idx].decode("utf-8")
247+
current_var = _decode_best_effort(buf[var_start:idx], encoding)
230248
elif buf[idx : idx + 1] == MSSP_VAL:
231249
idx += 1
232250
val_start = idx
233251
while idx < len(buf) and buf[idx : idx + 1] not in (MSSP_VAL, MSSP_VAR):
234252
idx += 1
235-
value = buf[val_start:idx].decode("utf-8")
253+
value = _decode_best_effort(buf[val_start:idx], encoding)
236254

237255
if current_var is not None:
238256
if current_var in result:

telnetlib3/server_fingerprinting.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from . import fingerprinting as _fps
2626
from .telopt import (
2727
VAR,
28+
MSSP,
2829
NAWS,
2930
LFLOW,
3031
TTYPE,
@@ -169,6 +170,13 @@ async def _fingerprint_session(
169170
probe_results = await probe_server_capabilities(writer)
170171
probe_time = time.time() - probe_start
171172

173+
# 5b. If server acknowledged MSSP but data hasn't arrived yet, poll briefly
174+
if writer.remote_option.enabled(MSSP) and writer.mssp_data is None:
175+
for _ in range(10):
176+
await asyncio.sleep(0.05)
177+
if writer.mssp_data is not None:
178+
break
179+
172180
# 6. Build session dicts
173181
session_data: dict[str, Any] = {
174182
"encoding": writer.environ_encoding,
@@ -177,6 +185,8 @@ async def _fingerprint_session(
177185
"banner_after_return": _format_banner(banner_after, encoding=writer.environ_encoding),
178186
"timing": {"probe": probe_time, "total": time.time() - start_time},
179187
}
188+
if writer.mssp_data is not None:
189+
session_data["mssp"] = writer.mssp_data
180190
session_entry: dict[str, Any] = {
181191
"host": host,
182192
"ip": (writer.get_extra_info("peername") or (host,))[0],

0 commit comments

Comments
 (0)