Skip to content

Commit 98fa757

Browse files
authored
more mud scanning improvements (jquast#121)
- Many terminals have custom palettes for colors 0-16 and that's great, but, when connecting to a remote machine we must assume the "usual" colors intended by the remote author, "solarized" and similar color schemes especially screw up artwork on bbs's, so we fix this by remapping those to their exact RGB color, new arguments: - telnetlib3-client --colormatch --color-brightness --color-contrast --background-color and --reverse-video. - support COM-PORT-OPTION, should help with bbs fingerprinting, many of them use serial bridges for their crappy old DOS programs. - better support misbehaving LINEMODE negotiation and IAC breakdown/freakouts of remote servers (caused usually by using wrong encoding). - bugfix LINEMODE FORWARDMASK and TTYPE IS exchange in client. - Experimental new "color palette", to help connect to legacy BBS's, it interprets the colors 0-16 to the likely author-intended* vga-style color codes. Many terminals have "custom" pallettes, and that's grea - new ``encoding=petscii`` and ``--encoding=atarist``
1 parent 139be0b commit 98fa757

27 files changed

Lines changed: 3063 additions & 166 deletions

docs/api/color_filter.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
color_filter
2+
------------
3+
4+
.. automodule:: telnetlib3.color_filter
5+
:members:
6+
:undoc-members:
7+
:show-inheritance:

docs/conf.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@
6060

6161
# General information about the project.
6262
project = "telnetlib3"
63-
copyright = "2013 Jeff Quast"
63+
import datetime
64+
copyright = f"2013-{datetime.datetime.now().year} Jeff Quast"
6465

6566
# The version info for the project you're documenting, acts as replacement for
6667
# |version| and |release|, also used in various other places throughout the

docs/history.rst

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,41 @@
11
History
22
=======
3+
2.4.0 *unreleased*
4+
* new: :mod:`telnetlib3.color_filter` module — translates 16-color ANSI SGR
5+
codes to 24-bit RGB from hardware palettes (EGA, CGA, VGA, Amiga, xterm).
6+
Enabled by default. New client CLI options: ``--colormatch``,
7+
``--color-brightness``, ``--color-contrast``, ``--background-color``,
8+
``--reverse-video``.
9+
* new: :func:`~telnetlib3.mud.zmp_decode`,
10+
:func:`~telnetlib3.mud.atcp_decode`, and
11+
:func:`~telnetlib3.mud.aardwolf_decode` decode functions for ZMP (option
12+
93), ATCP (option 200), and Aardwolf (option 102) MUD protocols.
13+
* new: :meth:`~telnetlib3.stream_writer.TelnetWriter.handle_zmp`,
14+
:meth:`~telnetlib3.stream_writer.TelnetWriter.handle_atcp`,
15+
:meth:`~telnetlib3.stream_writer.TelnetWriter.handle_aardwolf`,
16+
:meth:`~telnetlib3.stream_writer.TelnetWriter.handle_msp`, and
17+
:meth:`~telnetlib3.stream_writer.TelnetWriter.handle_mxp` callbacks for
18+
receiving MUD extended protocol subnegotiations, with accumulated data
19+
stored in ``zmp_data``, ``atcp_data``, and ``aardwolf_data`` attributes.
20+
* new: COM-PORT-OPTION (:rfc:`2217`) subnegotiation parsing with
21+
``comport_data`` attribute and
22+
:meth:`~telnetlib3.stream_writer.TelnetWriter.request_comport_signature`.
23+
* enhancement: ``telnetlib3-fingerprint`` now always probes extended MUD
24+
options (MSP, MXP, ZMP, AARDWOLF, ATCP) during server scans and captures
25+
ZMP, ATCP, Aardwolf, MXP, and COM-PORT data in session output.
26+
* enhancement: ``telnetlib3-fingerprint`` smart prompt detection —
27+
auto-answers yes/no, color, UTF-8 menu, ``who``, and ``help`` prompts.
28+
* enhancement: ``--banner-max-bytes`` option for ``telnetlib3-fingerprint``;
29+
default raised from 1024 to 65536.
30+
* enhancement: new ``--encoding=petscii`` and ``--encoding=atarist``
31+
* bugfix: rare LINEMODE ACK loop with misbehaving servers that re-send
32+
unchanged MODE without ACK.
33+
* bugfix: unknown IAC commands no longer raise ``ValueError``; treated as
34+
data.
35+
* bugfix: client no longer asserts on ``TTYPE IS`` from server.
36+
* bugfix: ``request_forwardmask()`` only called on server side.
37+
* change: ``wcwidth`` is now a required dependency.
38+
339

440
2.3.0
541
* bugfix: repeat "socket.send() raised exception." exceptions

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ classifiers = [
4343
"Topic :: Terminals :: Telnet",
4444
]
4545
requires-python = ">=3.9"
46+
dependencies = [
47+
"wcwidth>=0.2.13",
48+
]
4649

4750
[project.optional-dependencies]
4851
docs = [

telnetlib3/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from . import stream_reader
1717
from . import client_base
1818
from . import client_shell
19+
from . import color_filter
1920
from . import client
2021
from . import telopt
2122
from . import mud
@@ -26,6 +27,7 @@
2627
from . import server_fingerprinting
2728
if sys.platform != "win32":
2829
from . import fingerprinting_display # noqa: F401
30+
from . import encodings # noqa: F401 - registers custom codecs (petscii, atarist)
2931
from . import sync
3032
from .server_base import * # noqa
3133
from .server import * # noqa

telnetlib3/client.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,11 +528,48 @@ def _patched_connection_made(transport: asyncio.BaseTransport) -> None:
528528

529529
client_factory = _client_factory
530530

531+
# Wrap the shell callback to inject color filter when enabled
532+
colormatch: str = args["colormatch"]
533+
shell_callback = args["shell"]
534+
if colormatch.lower() != "none":
535+
# local
536+
from .color_filter import ( # pylint: disable=import-outside-toplevel
537+
PALETTES,
538+
ColorConfig,
539+
ColorFilter,
540+
)
541+
542+
if colormatch not in PALETTES:
543+
print(
544+
f"Unknown palette {colormatch!r}," f" available: {', '.join(sorted(PALETTES))}",
545+
file=sys.stderr,
546+
)
547+
sys.exit(1)
548+
color_config = ColorConfig(
549+
palette_name=colormatch,
550+
brightness=args["color_brightness"],
551+
contrast=args["color_contrast"],
552+
background_color=args["background_color"],
553+
reverse_video=args["reverse_video"],
554+
)
555+
color_filter = ColorFilter(color_config)
556+
original_shell = shell_callback
557+
558+
async def _color_shell(
559+
reader: Union[TelnetReader, TelnetReaderUnicode],
560+
writer_arg: Union[TelnetWriter, TelnetWriterUnicode],
561+
) -> None:
562+
# pylint: disable-next=protected-access
563+
writer_arg._color_filter = color_filter # type: ignore[union-attr]
564+
await original_shell(reader, writer_arg)
565+
566+
shell_callback = _color_shell
567+
531568
# Build connection kwargs explicitly to avoid pylint false positive
532569
connection_kwargs: Dict[str, Any] = {
533570
"encoding": args["encoding"],
534571
"tspeed": args["tspeed"],
535-
"shell": args["shell"],
572+
"shell": shell_callback,
536573
"term": args["term"],
537574
"force_binary": args["force_binary"],
538575
"encoding_errors": args["encoding_errors"],
@@ -607,6 +644,43 @@ def _get_argument_parser() -> argparse.ArgumentParser:
607644
metavar="OPT",
608645
help="always send DO for this option (name like GMCP or number, repeatable)",
609646
)
647+
parser.add_argument(
648+
"--colormatch",
649+
default="ega",
650+
metavar="PALETTE",
651+
help=(
652+
"translate basic 16-color ANSI codes to exact 24-bit RGB values"
653+
" from a named hardware palette, bypassing the terminal's custom"
654+
" palette to preserve intended MUD/BBS artwork colors"
655+
" (ega, cga, vga, amiga, xterm, none)"
656+
),
657+
)
658+
parser.add_argument(
659+
"--color-brightness",
660+
default=0.9,
661+
type=float,
662+
metavar="FLOAT",
663+
help="color brightness scale [0.0..1.0], where 1.0 is original",
664+
)
665+
parser.add_argument(
666+
"--color-contrast",
667+
default=0.8,
668+
type=float,
669+
metavar="FLOAT",
670+
help="color contrast scale [0.0..1.0], where 1.0 is original",
671+
)
672+
parser.add_argument(
673+
"--background-color",
674+
default="#101010",
675+
metavar="#RRGGBB",
676+
help="forced background color as hex RGB (near-black by default)",
677+
)
678+
parser.add_argument(
679+
"--reverse-video",
680+
action="store_true",
681+
default=False,
682+
help="swap foreground/background for light-background terminals",
683+
)
610684
return parser
611685

612686

@@ -627,6 +701,20 @@ def _parse_option_arg(value: str) -> bytes:
627701
return bytes([int(value)])
628702

629703

704+
def _parse_background_color(value: str) -> Tuple[int, int, int]:
705+
"""
706+
Parse hex color string to RGB tuple.
707+
708+
:param value: Color string like ``"#RRGGBB"`` or ``"RRGGBB"``.
709+
:returns: (R, G, B) tuple with values 0-255.
710+
:raises ValueError: When *value* is not a valid hex color.
711+
"""
712+
h = value.lstrip("#")
713+
if len(h) != 6:
714+
raise ValueError(f"invalid hex color: {value!r}")
715+
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
716+
717+
630718
def _transform_args(args: argparse.Namespace) -> Dict[str, Any]:
631719
return {
632720
"host": args.host,
@@ -645,6 +733,11 @@ def _transform_args(args: argparse.Namespace) -> Dict[str, Any]:
645733
"send_environ": tuple(v.strip() for v in args.send_environ.split(",") if v.strip()),
646734
"always_will": {_parse_option_arg(v) for v in args.always_will},
647735
"always_do": {_parse_option_arg(v) for v in args.always_do},
736+
"colormatch": args.colormatch,
737+
"color_brightness": args.color_brightness,
738+
"color_contrast": args.color_contrast,
739+
"background_color": _parse_background_color(args.background_color),
740+
"reverse_video": args.reverse_video,
648741
}
649742

650743

@@ -741,6 +834,9 @@ def _get_fingerprint_argument_parser() -> argparse.ArgumentParser:
741834
parser.add_argument(
742835
"--banner-max-wait", default=8.0, type=float, help="max seconds to wait for banner data"
743836
)
837+
parser.add_argument(
838+
"--banner-max-bytes", default=65536, type=int, help="max bytes per banner read call"
839+
)
744840
return parser
745841

746842

@@ -778,6 +874,7 @@ async def run_fingerprint_client() -> None:
778874
mssp_wait=args.mssp_wait,
779875
banner_quiet_time=args.banner_quiet_time,
780876
banner_max_wait=args.banner_max_wait,
877+
banner_max_bytes=args.banner_max_bytes,
781878
)
782879

783880
# Parse --always-will/--always-do option names/numbers

telnetlib3/client_base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def connection_lost(self, exc: Optional[Exception]) -> None:
123123
# the StreamReader will receive eof.
124124
self._waiter_connected.set_result(None)
125125

126-
if self.shell is None:
126+
if self.shell is None and not self.waiter_closed.done():
127127
# when a shell is defined, we allow the completion of the coroutine
128128
# to set the result of waiter_closed.
129129
self.waiter_closed.set_result(weakref.proxy(self))
@@ -200,6 +200,7 @@ def begin_shell(self, future: asyncio.Future[None]) -> None:
200200
lambda fut_obj: (
201201
self.waiter_closed.set_result(weakref.proxy(self))
202202
if self.waiter_closed is not None
203+
and not self.waiter_closed.done()
203204
else None
204205
)
205206
)

telnetlib3/client_shell.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ def _on_winch() -> None:
242242
if telnet_task in wait_for:
243243
telnet_task.cancel()
244244
wait_for.remove(telnet_task)
245+
_cf = getattr(telnet_writer, "_color_filter", None)
246+
if _cf is not None:
247+
_flush = _cf.flush()
248+
if _flush:
249+
stdout.write(_flush.encode())
245250
stdout.write(f"\033[m{linesep}Connection closed.{linesep}".encode())
246251
# Cleanup resize handler on local escape close
247252
if term._istty and remove_winch: # pylint: disable=protected-access
@@ -273,6 +278,11 @@ def _on_winch() -> None:
273278
if stdin_task in wait_for:
274279
stdin_task.cancel()
275280
wait_for.remove(stdin_task)
281+
_cf = getattr(telnet_writer, "_color_filter", None)
282+
if _cf is not None:
283+
_flush = _cf.flush()
284+
if _flush:
285+
stdout.write(_flush.encode())
276286
stdout.write(
277287
f"\033[m{linesep}Connection closed by foreign host.{linesep}".encode()
278288
)
@@ -289,6 +299,9 @@ def _on_winch() -> None:
289299
except Exception: # pylint: disable=broad-exception-caught
290300
pass
291301
else:
302+
_cf = getattr(telnet_writer, "_color_filter", None)
303+
if _cf is not None:
304+
out = _cf.filter(out)
292305
stdout.write(out.encode() or b":?!?:")
293306
telnet_task = accessories.make_reader_task(telnet_reader, size=2**24)
294307
wait_for.add(telnet_task)

0 commit comments

Comments
 (0)