Skip to content

Commit eb66772

Browse files
authored
jq/telnetlib3 fingerprint server (jquast#117)
* new: ``telnetlib3-fingerprint-server`` CLI with extended ``NEW_ENVIRON`` for client fingerprinting (uses ``FingerprintingServer`` protocol factory).
1 parent 7a7c745 commit eb66772

8 files changed

Lines changed: 156 additions & 14 deletions

File tree

bin/server_mud.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
$ python bin/server_mud.py
77
$ telnet localhost 6023
88
"""
9+
910
from __future__ import annotations
1011

1112
# std imports
@@ -858,8 +859,6 @@ async def main(argv: list[str] | None = None) -> None:
858859
f"{SERVER_NAME} running on"
859860
f" {args.host}:{args.port}\n"
860861
f"Connect with: telnet {args.host} {args.port}\n"
861-
"Or use GMCP client:"
862-
" python bin/client_gmcp.py\n"
863862
"Press Ctrl+C to stop"
864863
)
865864
await server.wait_closed()

docs/guidebook.rst

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -471,17 +471,21 @@ Install with optional dependencies for full fingerprinting support
471471

472472
pip install telnetlib3[extras]
473473

474+
A dedicated CLI entry point is provided::
474475

475-
::
476+
telnetlib3-fingerprint-server --data-dir data
476477

477-
TELNETLIB3_DATA_DIR=data telnetlib3-server --shell telnetlib3.fingerprinting_server_shell
478+
This uses :class:`~telnetlib3.fingerprinting.FingerprintingServer` as the
479+
protocol factory and :func:`~telnetlib3.fingerprinting.fingerprinting_server_shell`
480+
as the default shell. All ``telnetlib3-server`` options (``--host``, ``--port``,
481+
etc.) are accepted.
478482

479483
Storage
480484
-------
481485

482486
Results are saved as JSON files organized by fingerprint hash::
483487

484-
$TELNETLIB3_DATA_DIR/client/<telnet-hash>/<terminal-hash>/
488+
<data-dir>/client/<telnet-hash>/<terminal-hash>/
485489

486490
Moderating
487491
----------

docs/history.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ History
99
* new: ``connect_timeout`` arguments for client and ``--connect-timeout``
1010
Client CLI argument, :ghissue:`30`.
1111
* bugfix: missing LICENSE.txt in sdist file.
12+
* new: ``telnetlib3-fingerprint-server`` CLI with extended ``NEW_ENVIRON``
13+
for client fingerprinting (uses ``FingerprintingServer`` protocol factory).
14+
* note: fingerprint hashes for MUD clients detected via GMCP/MSDP
15+
may change due to improved client classification.
1216

1317
2.2.0
1418
* bugfix: workaround for Microsoft Telnet client crash on

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ extras = [
5858
[project.scripts]
5959
telnetlib3-server = "telnetlib3.server:main"
6060
telnetlib3-client = "telnetlib3.client:main"
61+
telnetlib3-fingerprint-server = "telnetlib3.fingerprinting:fingerprint_server_main"
6162

6263
[project.urls]
6364
Homepage = "https://github.com/jquast/telnetlib3"

telnetlib3/fingerprinting.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616
import asyncio
1717
import hashlib
1818
import logging
19+
import argparse
1920
import datetime
2021
from typing import Any, Dict, List, Tuple, Union, Callable, Optional, cast
2122

2223
# local
2324
from . import slc
25+
from .server import TelnetServer # pylint: disable=cyclic-import
2426
from .telopt import (
2527
BM,
2628
DO,
@@ -120,7 +122,9 @@
120122

121123
__all__ = (
122124
"ENVIRON_EXTENDED",
125+
"FingerprintingServer",
123126
"FingerprintingTelnetServer",
127+
"fingerprint_server_main",
124128
"fingerprinting_server_shell",
125129
"fingerprinting_post_script",
126130
"get_client_fingerprint",
@@ -190,6 +194,19 @@ def on_request_environ(self) -> list[Union[str, bytes]]:
190194
return base[:insert_at] + extra + base[insert_at:]
191195

192196

197+
class FingerprintingServer(FingerprintingTelnetServer, TelnetServer):
198+
"""
199+
:class:`~telnetlib3.server.TelnetServer` with extended ``NEW_ENVIRON``.
200+
201+
Combines :class:`FingerprintingTelnetServer` with :class:`~telnetlib3.server.TelnetServer`
202+
so that :func:`fingerprinting_server_shell` receives the full set of
203+
environment variables needed for stable fingerprint hashes.
204+
205+
Used as the default ``protocol_factory`` by
206+
:func:`fingerprint_server_main` / ``telnetlib3-fingerprint-server`` CLI.
207+
"""
208+
209+
193210
# Timeout for probe_client_capabilities in _run_probe (seconds)
194211
_PROBE_TIMEOUT = 0.5
195212

@@ -993,6 +1010,44 @@ def fingerprinting_post_script(filepath: str) -> None:
9931010
_fps(filepath)
9941011

9951012

1013+
def fingerprint_server_main() -> None:
1014+
"""
1015+
Entry point for ``telnetlib3-fingerprint-server`` CLI.
1016+
1017+
Reuses :func:`~telnetlib3.server.parse_server_args` and
1018+
:func:`~telnetlib3.server.run_server` with
1019+
:class:`FingerprintingServer` as the default protocol factory
1020+
and :func:`fingerprinting_server_shell` as the default shell.
1021+
1022+
Accepts ``--data-dir`` to set the fingerprint data directory.
1023+
Falls back to the ``TELNETLIB3_DATA_DIR`` environment variable.
1024+
"""
1025+
# pylint: disable=import-outside-toplevel,global-statement
1026+
# local import is required to prevent circular imports
1027+
# local
1028+
from .server import _config, run_server, parse_server_args # noqa: PLC0415
1029+
1030+
global DATA_DIR
1031+
# Extract --data-dir before parse_server_args() sees argv.
1032+
pre = argparse.ArgumentParser(add_help=False)
1033+
pre.add_argument(
1034+
"--data-dir",
1035+
default=None,
1036+
help="directory for fingerprint data" " (default: $TELNETLIB3_DATA_DIR)",
1037+
)
1038+
pre_args, remaining = pre.parse_known_args()
1039+
sys.argv[1:] = remaining
1040+
1041+
if pre_args.data_dir is not None:
1042+
DATA_DIR = pre_args.data_dir
1043+
1044+
args = parse_server_args()
1045+
if args["shell"] is _config.shell:
1046+
args["shell"] = fingerprinting_server_shell
1047+
args["protocol_factory"] = FingerprintingServer
1048+
asyncio.run(run_server(**args))
1049+
1050+
9961051
def main() -> None:
9971052
"""CLI entry point for fingerprinting post-processing."""
9981053
if len(sys.argv) != 2:

telnetlib3/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,7 @@ async def run_server( # pylint: disable=too-many-positional-arguments,too-many-
10461046
pty_fork_limit: int = _config.pty_fork_limit,
10471047
status_interval: int = _config.status_interval,
10481048
never_send_ga: bool = _config.never_send_ga,
1049+
protocol_factory: Optional[Type[asyncio.Protocol]] = None,
10491050
) -> None:
10501051
"""
10511052
Program entry point for server daemon.
@@ -1125,6 +1126,7 @@ async def guarded_shell(
11251126
host,
11261127
port,
11271128
shell=shell,
1129+
protocol_factory=protocol_factory,
11281130
encoding=encoding,
11291131
force_binary=force_binary,
11301132
never_send_ga=never_send_ga,

telnetlib3/tests/test_encoding.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,7 @@ async def test_telnet_client_and_server_encoding_bidirectional(bind_host, unused
109109

110110
async def test_telnet_server_encoding_by_LANG(bind_host, unused_tcp_port):
111111
"""Server's encoding negotiated by LANG value."""
112-
async with create_server(
113-
host=bind_host, port=unused_tcp_port, connect_maxwait=0.5
114-
) as server:
112+
async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server:
115113
async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer):
116114
writer.write(IAC + DO + BINARY)
117115
writer.write(IAC + WILL + BINARY)
@@ -136,9 +134,7 @@ async def test_telnet_server_encoding_by_LANG(bind_host, unused_tcp_port):
136134

137135
async def test_telnet_server_encoding_LANG_no_encoding_suffix(bind_host, unused_tcp_port):
138136
"""Server falls back to default when LANG has no encoding suffix."""
139-
async with create_server(
140-
host=bind_host, port=unused_tcp_port, connect_maxwait=0.5
141-
) as server:
137+
async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server:
142138
async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer):
143139
writer.write(IAC + DO + BINARY)
144140
writer.write(IAC + WILL + BINARY)
@@ -161,9 +157,7 @@ async def test_telnet_server_encoding_LANG_no_encoding_suffix(bind_host, unused_
161157

162158
async def test_telnet_server_encoding_LANG_invalid_encoding(bind_host, unused_tcp_port):
163159
"""Server falls back to default when LANG has unknown encoding."""
164-
async with create_server(
165-
host=bind_host, port=unused_tcp_port, connect_maxwait=0.5
166-
) as server:
160+
async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server:
167161
async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer):
168162
writer.write(IAC + DO + BINARY)
169163
writer.write(IAC + WILL + BINARY)

telnetlib3/tests/test_fingerprinting.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,4 +1006,87 @@ def test_process_client_fingerprint_skips_ucs_detect_for_mud(monkeypatch, tmp_pa
10061006
fpd._process_client_fingerprint(filepath, data)
10071007
except (ImportError, AttributeError, TypeError):
10081008
pass
1009+
capsys.readouterr()
10091010
assert not ucs_called
1011+
1012+
1013+
def test_protocol_fingerprint_hash_stability():
1014+
"""Hash must not change across releases for the same probe data."""
1015+
w = MockWriter(
1016+
extra={"TERM": "xterm", "HOME": "/home/user", "USER": "alice", "SHELL": "/bin/bash"}
1017+
)
1018+
w._protocol = MockProtocol({"HOME": "/home/user", "USER": "alice", "SHELL": "/bin/bash"})
1019+
probe = {
1020+
"BINARY": {"status": "WILL", "opt": fps.BINARY},
1021+
"TTYPE": {"status": "WILL", "opt": fps.TTYPE},
1022+
"SGA": {"status": "WONT", "opt": fps.SGA},
1023+
}
1024+
fp = fps._create_protocol_fingerprint(w, probe)
1025+
assert fps._hash_fingerprint(fp) == "426327fe80f38c2c"
1026+
1027+
1028+
def test_fingerprinting_server_on_request_environ():
1029+
"""FingerprintingServer includes HOME and SHELL in environ request."""
1030+
srv = fps.FingerprintingServer.__new__(fps.FingerprintingServer)
1031+
env = srv.on_request_environ()
1032+
assert "HOME" in env
1033+
assert "SHELL" in env
1034+
assert "USER" in env
1035+
1036+
1037+
def test_fingerprint_server_shell_has_no_protocol_factory():
1038+
"""Shell is a plain callback, not annotated with protocol_factory."""
1039+
assert not hasattr(fps.fingerprinting_server_shell, "protocol_factory")
1040+
1041+
1042+
def test_fingerprint_server_main_exists():
1043+
"""Entry point function is importable."""
1044+
assert callable(fps.fingerprint_server_main)
1045+
1046+
1047+
def _noop_asyncio_run(coro):
1048+
"""Discard a coroutine without running it (avoids RuntimeWarning)."""
1049+
coro.close()
1050+
1051+
1052+
def test_fingerprint_server_main_data_dir_flag(tmp_path, monkeypatch):
1053+
"""--data-dir sets DATA_DIR and passes remaining args through."""
1054+
data_dir = str(tmp_path / "fp-data")
1055+
monkeypatch.setattr(sys, "argv", ["prog", "--data-dir", data_dir, "127.0.0.1", "9999"])
1056+
monkeypatch.setattr("telnetlib3.fingerprinting.asyncio.run", _noop_asyncio_run)
1057+
1058+
captured: dict = {}
1059+
# local
1060+
from telnetlib3.server import parse_server_args
1061+
1062+
original_parse = parse_server_args
1063+
1064+
def patched_parse() -> dict:
1065+
result = original_parse()
1066+
captured.update(result)
1067+
return result
1068+
1069+
monkeypatch.setattr("telnetlib3.server.parse_server_args", patched_parse)
1070+
1071+
old_data_dir = fps.DATA_DIR
1072+
try:
1073+
fps.fingerprint_server_main()
1074+
assert fps.DATA_DIR == data_dir
1075+
assert captured["host"] == "127.0.0.1"
1076+
assert captured["port"] == 9999
1077+
finally:
1078+
fps.DATA_DIR = old_data_dir
1079+
1080+
1081+
def test_fingerprint_server_main_env_fallback(monkeypatch):
1082+
"""DATA_DIR unchanged when --data-dir is not provided."""
1083+
monkeypatch.setattr(sys, "argv", ["prog"])
1084+
monkeypatch.setattr("telnetlib3.fingerprinting.asyncio.run", _noop_asyncio_run)
1085+
1086+
old_data_dir = fps.DATA_DIR
1087+
try:
1088+
fps.DATA_DIR = "/original"
1089+
fps.fingerprint_server_main()
1090+
assert fps.DATA_DIR == "/original"
1091+
finally:
1092+
fps.DATA_DIR = old_data_dir

0 commit comments

Comments
 (0)