Skip to content

Commit 8de6946

Browse files
authored
Mud Improvements (round 2) (jquast#120)
- bugfix: GMCP, MSDP, and MSSP decoding now uses --encoding when set, falling back to latin-1 - bugfix: NEW_ENVIRON SEND with empty payload now correctly interpreted as "send all" per RFC 1572 - new: --always-will, --always-do, --scan-type, --mssp-wait, --banner-quiet-time, --banner-max-wait options for telnetlib3-fingerprint
1 parent 4bc79f7 commit 8de6946

13 files changed

Lines changed: 601 additions & 130 deletions

docs/history.rst

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ History
99
``NotImplementedError``; the mask is accepted (logged only).
1010
* bugfix: echo doubling in ``--pty-exec`` without ``--pty-raw`` (linemode).
1111
* bugfix: missing LICENSE.txt in sdist file.
12+
* bugfix: GMCP, MSDP, and MSSP decoding now uses ``--encoding`` when set,
13+
falling back to latin-1 for non-UTF-8 bytes instead of lossy replacement.
14+
* bugfix: ``NEW_ENVIRON SEND`` with empty payload now correctly
15+
interpreted as "send all" per :rfc:`1572`.
1216
* new: :mod:`telnetlib3.mud` module with encode/decode functions for
1317
GMCP (option 201), MSDP (option 69), and MSSP (option 70) MUD telnet
1418
protocols.
@@ -29,10 +33,10 @@ History
2933
``DONT``/``WONT`` instead of raising ``ValueError``.
3034
* enhancement: ``NEW_ENVIRON SEND`` and response logging improved --
3135
``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.
3436
* enhancement: ``telnetlib3-fingerprint`` now probes MSDP and MSSP options
3537
and captures MSSP server status data in session output.
38+
* new: ``--always-will``, ``--always-do``, ``--scan-type``, ``--mssp-wait``,
39+
``--banner-quiet-time``, ``--banner-max-wait`` options for ``telnetlib3-fingerprint``.
3640

3741
2.2.0
3842
* bugfix: workaround for Microsoft Telnet client crash on

telnetlib3/client.py

Lines changed: 129 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -502,8 +502,34 @@ async def run_client() -> None:
502502
)
503503
log.debug(config_msg)
504504

505+
always_will: set[bytes] = args["always_will"]
506+
always_do: set[bytes] = args["always_do"]
507+
508+
# Wrap client factory to inject always_will/always_do before negotiation
509+
client_factory: Optional[Callable[..., client_base.BaseClient]] = None
510+
if always_will or always_do:
511+
512+
def _client_factory(**kwargs: Any) -> client_base.BaseClient:
513+
client: TelnetClient
514+
if sys.platform != "win32" and sys.stdin.isatty():
515+
client = TelnetTerminalClient(**kwargs)
516+
else:
517+
client = TelnetClient(**kwargs)
518+
orig_connection_made = client.connection_made
519+
520+
def _patched_connection_made(transport: asyncio.BaseTransport) -> None:
521+
orig_connection_made(transport)
522+
assert client.writer is not None
523+
client.writer.always_will = always_will
524+
client.writer.always_do = always_do
525+
526+
client.connection_made = _patched_connection_made # type: ignore[method-assign]
527+
return client
528+
529+
client_factory = _client_factory
530+
505531
# Build connection kwargs explicitly to avoid pylint false positive
506-
connection_kwargs = {
532+
connection_kwargs: Dict[str, Any] = {
507533
"encoding": args["encoding"],
508534
"tspeed": args["tspeed"],
509535
"shell": args["shell"],
@@ -514,6 +540,8 @@ async def run_client() -> None:
514540
"connect_timeout": args["connect_timeout"],
515541
"send_environ": args["send_environ"],
516542
}
543+
if client_factory is not None:
544+
connection_kwargs["client_factory"] = client_factory
517545

518546
# connect
519547
_, writer = await open_connection(args["host"], args["port"], **connection_kwargs)
@@ -565,9 +593,40 @@ def _get_argument_parser() -> argparse.ArgumentParser:
565593
default="TERM,LANG,COLUMNS,LINES,COLORTERM",
566594
help="comma-separated environment variables to send (NEW_ENVIRON)",
567595
)
596+
parser.add_argument(
597+
"--always-will",
598+
action="append",
599+
default=[],
600+
metavar="OPT",
601+
help="always send WILL for this option (name like MXP or number, repeatable)",
602+
)
603+
parser.add_argument(
604+
"--always-do",
605+
action="append",
606+
default=[],
607+
metavar="OPT",
608+
help="always send DO for this option (name like GMCP or number, repeatable)",
609+
)
568610
return parser
569611

570612

613+
def _parse_option_arg(value: str) -> bytes:
614+
"""
615+
Resolve a telnet option name or integer to option bytes.
616+
617+
:param value: Option name (e.g. ``"MXP"``) or decimal byte value (e.g. ``"91"``).
618+
:returns: Single-byte option value.
619+
:raises ValueError: When *value* is not a known name or valid integer.
620+
"""
621+
# local
622+
from .telopt import option_from_name # pylint: disable=import-outside-toplevel
623+
624+
try:
625+
return option_from_name(value)
626+
except KeyError:
627+
return bytes([int(value)])
628+
629+
571630
def _transform_args(args: argparse.Namespace) -> Dict[str, Any]:
572631
return {
573632
"host": args.host,
@@ -584,12 +643,18 @@ def _transform_args(args: argparse.Namespace) -> Dict[str, Any]:
584643
"connect_minwait": args.connect_minwait,
585644
"connect_timeout": args.connect_timeout,
586645
"send_environ": tuple(v.strip() for v in args.send_environ.split(",") if v.strip()),
646+
"always_will": {_parse_option_arg(v) for v in args.always_will},
647+
"always_do": {_parse_option_arg(v) for v in args.always_do},
587648
}
588649

589650

590651
def main() -> None:
591652
"""Entry point for telnetlib3-client command."""
592-
asyncio.run(run_client())
653+
try:
654+
asyncio.run(run_client())
655+
except OSError as err:
656+
print(f"Error: {err}", file=sys.stderr)
657+
sys.exit(1)
593658

594659

595660
def _get_fingerprint_argument_parser() -> argparse.ArgumentParser:
@@ -634,13 +699,48 @@ def _get_fingerprint_argument_parser() -> argparse.ArgumentParser:
634699
parser.add_argument(
635700
"--ttype", default="VT100", help="terminal type sent in response to TTYPE requests"
636701
)
702+
parser.add_argument(
703+
"--scan-type",
704+
choices=["quick", "full"],
705+
default="quick",
706+
help="probe depth: 'quick' probes core options only, " "'full' includes legacy options",
707+
)
637708
parser.add_argument(
638709
"--send-env",
639710
action="append",
640711
metavar="KEY=VALUE",
641712
default=[],
642713
help="environment variable to send (repeatable)",
643714
)
715+
parser.add_argument(
716+
"--always-will",
717+
action="append",
718+
default=[],
719+
metavar="OPT",
720+
help="always send WILL for this option (name like MXP or number, repeatable)",
721+
)
722+
parser.add_argument(
723+
"--always-do",
724+
action="append",
725+
default=[],
726+
metavar="OPT",
727+
help="always send DO for this option (name like GMCP or number, repeatable)",
728+
)
729+
parser.add_argument(
730+
"--mssp-wait",
731+
default=5.0,
732+
type=float,
733+
help="max seconds since connect to wait for MSSP data",
734+
)
735+
parser.add_argument(
736+
"--banner-quiet-time",
737+
default=2.0,
738+
type=float,
739+
help="seconds of silence before considering banner complete",
740+
)
741+
parser.add_argument(
742+
"--banner-max-wait", default=8.0, type=float, help="max seconds to wait for banner data"
743+
)
644744
return parser
645745

646746

@@ -674,8 +774,16 @@ async def run_fingerprint_client() -> None:
674774
silent=args.silent,
675775
set_name=args.set_name,
676776
environ_encoding=args.stream_encoding,
777+
scan_type=args.scan_type,
778+
mssp_wait=args.mssp_wait,
779+
banner_quiet_time=args.banner_quiet_time,
780+
banner_max_wait=args.banner_max_wait,
677781
)
678782

783+
# Parse --always-will/--always-do option names/numbers
784+
fp_always_will = {_parse_option_arg(v) for v in args.always_will}
785+
fp_always_do = {_parse_option_arg(v) for v in args.always_do}
786+
679787
# Parse --send-env KEY=VALUE pairs
680788
extra_env: Dict[str, str] = {}
681789
for item in args.send_env:
@@ -705,6 +813,8 @@ def patched_connection_made(transport: asyncio.BaseTransport) -> None:
705813
orig_connection_made(transport)
706814
assert client.writer is not None
707815
client.writer.environ_encoding = environ_encoding
816+
client.writer.always_will = fp_always_will
817+
client.writer.always_do = fp_always_do
708818

709819
def patched_send_env(keys: Sequence[str]) -> Dict[str, Any]:
710820
result = orig_send_env(keys)
@@ -718,18 +828,22 @@ def patched_send_env(keys: Sequence[str]) -> Dict[str, Any]:
718828

719829
waiter_closed: asyncio.Future[None] = asyncio.get_event_loop().create_future()
720830

721-
_, writer = await open_connection(
722-
host=args.host,
723-
port=args.port,
724-
client_factory=fingerprint_client_factory,
725-
shell=shell,
726-
encoding=False,
727-
term=ttype,
728-
connect_minwait=2.0,
729-
connect_maxwait=4.0,
730-
connect_timeout=args.connect_timeout,
731-
waiter_closed=waiter_closed,
732-
)
831+
try:
832+
_, writer = await open_connection(
833+
host=args.host,
834+
port=args.port,
835+
client_factory=fingerprint_client_factory,
836+
shell=shell,
837+
encoding=False,
838+
term=ttype,
839+
connect_minwait=2.0,
840+
connect_maxwait=4.0,
841+
connect_timeout=args.connect_timeout,
842+
waiter_closed=waiter_closed,
843+
)
844+
except OSError as err:
845+
log.error("%s:%d: %s", args.host, args.port, err)
846+
raise
733847

734848
assert writer.protocol is not None
735849
assert isinstance(writer.protocol, client_base.BaseClient)
@@ -740,7 +854,7 @@ def fingerprint_main() -> None:
740854
"""Entry point for ``telnetlib3-fingerprint`` command."""
741855
try:
742856
asyncio.run(run_fingerprint_client())
743-
except ConnectionError as err:
857+
except OSError as err:
744858
print(f"Error: {err}", file=sys.stderr)
745859
sys.exit(1)
746860

telnetlib3/client_base.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
# local
1717
from ._types import ShellCallback
18-
from .telopt import theNULL, name_commands
18+
from .telopt import DO, WILL, theNULL, name_commands
1919
from .stream_reader import TelnetReader, TelnetReaderUnicode
2020
from .stream_writer import TelnetWriter, TelnetWriterUnicode
2121

@@ -272,6 +272,13 @@ def begin_negotiation(self) -> None:
272272
self._check_later = asyncio.get_event_loop().call_soon(self._check_negotiation_timer)
273273
self._tasks.append(self._check_later)
274274

275+
# Send proactive WILL/DO for any "always" options
276+
if self.writer is not None:
277+
for opt in self.writer.always_will:
278+
self.writer.iac(WILL, opt)
279+
for opt in self.writer.always_do:
280+
self.writer.iac(DO, opt)
281+
275282
def encoding(self, outgoing: bool = False, incoming: bool = False) -> Union[str, bool]:
276283
"""
277284
Encoding that should be used for the direction indicated.

telnetlib3/fingerprinting.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ class ProbeResult(TypedDict, total=False):
9898
)
9999

100100
# Maximum files per protocol-fingerprint folder
101-
FINGERPRINT_MAX_FILES = int(os.environ.get("TELNETLIB3_FINGERPRINT_MAX_FILES", "200"))
101+
FINGERPRINT_MAX_FILES = int(os.environ.get("TELNETLIB3_FINGERPRINT_MAX_FILES", "1000"))
102102

103103
# Maximum number of unique fingerprint folders
104104
FINGERPRINT_MAX_FINGERPRINTS = int(
@@ -292,6 +292,7 @@ class FingerprintingServer(FingerprintingTelnetServer, TelnetServer):
292292
]
293293

294294
ALL_PROBE_OPTIONS = CORE_OPTIONS + MUD_OPTIONS + LEGACY_OPTIONS
295+
QUICK_PROBE_OPTIONS = CORE_OPTIONS + MUD_OPTIONS
295296

296297
# All known options including extended, for display/name lookup only
297298
_ALL_KNOWN_OPTIONS = ALL_PROBE_OPTIONS + EXTENDED_OPTIONS

0 commit comments

Comments
 (0)