Skip to content

Commit 32a7845

Browse files
authored
telnetlib3-fingerprint host [port] CLI tool (jquast#118)
* bugfix: repeat "socket.send() raised exception." exceptions * bugfix: echo doubling in ``--pty-exec`` without ``--pty-raw`` (linemode). * new: ``telnetlib3-fingerprint`` CLI for fingerprinting the given remote server, probing telnet option support and capturing banners.
1 parent eb66772 commit 32a7845

24 files changed

Lines changed: 1777 additions & 297 deletions

README.rst

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,29 +29,25 @@
2929
Introduction
3030
============
3131

32-
``telnetlib3`` is a feature-rich Telnet Server and Client Protocol library
32+
``telnetlib3`` is a feature-rich Telnet Server, Client, and Protocol library
3333
for Python 3.9 and newer.
3434

3535
This library supports both modern asyncio_ *and* legacy `Blocking API`_.
3636

3737
The python telnetlib.py_ module removed by Python 3.13 is also re-distributed as-is, as a backport.
3838

39-
telnetlib3 provides multiple interfaces for working with the Telnet protocol:
39+
See the `Guidebook`_ for examples and the `API documentation`_.
4040

4141
Asyncio Protocol
4242
----------------
4343

44-
Modern async/await interface for both client and server, supporting concurrent
45-
connections. See the `Guidebook`_ for examples and the `API documentation`_.
44+
The core protocol and CLI utilities are written using an `Asyncio Interface`_.
4645

4746
Blocking API
4847
------------
4948

50-
A traditional synchronous interface modeled after telnetlib.py_ (client) and miniboa_ (server),
51-
with various enhancements in protocol negotiation is provided. Blocking API calls for complex
52-
arrangements of clients and servers typically require threads.
53-
54-
See `sync API documentation`_ for more.
49+
A Synchronous interface, modeled after telnetlib.py_ (client) and miniboa_ (server), with various
50+
enhancements in protocol negotiation is also provided. See `sync API documentation`_ for more.
5551

5652
Command-line Utilities
5753
----------------------
@@ -66,16 +62,38 @@ program.
6662

6763
::
6864

65+
# utf8 roguelike server
6966
telnetlib3-client nethack.alt.org
67+
# utf8 bbs
7068
telnetlib3-client xibalba.l33t.codes 44510
69+
# automatic communication with telnet server
7170
telnetlib3-client --shell bin.client_wargame.shell 1984.ws 666
71+
# run a server with default shell
72+
telnetlib3-server
73+
# or custom port and ip and shell
7274
telnetlib3-server 0.0.0.0 1984 --shell=bin.server_wargame.shell
73-
telnetlib3-server --pty-exec /bin/bash -- --login
75+
# run an external program with a pseudo-terminal
76+
telnetlib3-server --pty-exec /bin/bash --pty-raw -- --login
77+
# or a simple linemode program, bc (calculator)
78+
telnetlib3-server --pty-exec /bin/bc
79+
80+
81+
There are also fingerprinting CLIs, ``telnetlib3-fingerprint`` and
82+
``telnetlib3-fingerprint-server``
83+
84+
::
85+
86+
# host a server, wait for clients to connect and fingerprint them,
87+
telnetlib3-fingerprint-server
88+
89+
# report fingerprint of telnet server on 1984.ws
90+
telnetlib3-fingerprint 1984.ws
91+
7492

7593
Legacy telnetlib
7694
----------------
7795

78-
This library contains an unadulterated copy of Python 3.12's telnetlib.py_,
96+
This library contains an *unadulterated copy* of Python 3.12's telnetlib.py_,
7997
from the standard library before it was removed in Python 3.13.
8098

8199
To migrate code, change import statements:
@@ -206,6 +224,8 @@ The following RFC specifications are implemented:
206224
.. _rfc-2066: https://www.rfc-editor.org/rfc/rfc2066.txt
207225
.. _`bin/`: https://github.com/jquast/telnetlib3/tree/master/bin
208226
.. _telnetlib.py: https://docs.python.org/3.12/library/telnetlib.html
227+
.. _Asyncio Interface: https://telnetlib3.readthedocs.io/en/latest/guidebook.html#asyncio-interface
228+
.. _Blocking API: https://telnetlib3.readthedocs.io/en/latest/guidebook.html#blocking-interface
209229
.. _Guidebook: https://telnetlib3.readthedocs.io/en/latest/guidebook.html
210230
.. _API documentation: https://telnetlib3.readthedocs.io/en/latest/api.html
211231
.. _sync API documentation: https://telnetlib3.readthedocs.io/en/latest/api/sync.html

bin/client_wargame.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async def shell(reader, writer):
3030
break
3131
if "?" in outp:
3232
# Reply to all questions with 'y'
33-
writer.write("y")
33+
writer.write("y\r\n")
3434

3535
# Display all server output
3636
print(outp, flush=True, end="")

bin/moderate_fingerprints.py

Lines changed: 154 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,33 @@
77
import sys
88
import json
99
import shutil
10+
import signal
11+
import socket
12+
import argparse
1013
import subprocess
1114
import collections
1215
from pathlib import Path
1316

17+
try:
18+
# 3rd party
19+
from wcwidth import iter_sequences, strip_sequences
20+
21+
_HAS_WCWIDTH = True
22+
except ImportError:
23+
_HAS_WCWIDTH = False
24+
1425
_BAT = shutil.which("bat") or shutil.which("batcat")
1526
_JQ = shutil.which("jq")
1627
_UNKNOWN = "0" * 16
1728
_PROBES = {
1829
"telnet-probe": ("telnet-client", "telnet-client-revision"),
1930
"terminal-probe": ("terminal-emulator", "terminal-emulator-revision"),
31+
"server-probe": ("telnet-server", "telnet-server-revision"),
2032
}
2133

2234

2335
def _iter_files(data_dir):
24-
"""Yield (path, data) for each client JSON file."""
36+
"""Yield (path, data) for each fingerprint JSON file."""
2537
client_base = data_dir / "client"
2638
if client_base.is_dir():
2739
for path in sorted(client_base.glob("*/*/*.json")):
@@ -30,6 +42,14 @@ def _iter_files(data_dir):
3042
yield path, json.load(f)
3143
except (OSError, json.JSONDecodeError):
3244
continue
45+
server_base = data_dir / "server"
46+
if server_base.is_dir():
47+
for path in sorted(server_base.glob("*/*.json")):
48+
try:
49+
with open(path, encoding="utf-8") as f:
50+
yield path, json.load(f)
51+
except (OSError, json.JSONDecodeError):
52+
continue
3353

3454

3555
def _print_json(label, data):
@@ -79,6 +99,85 @@ def _print_terminal_context(session_data):
7999
print(f" ambiguous_width: {aw}")
80100

81101

102+
def _resolve_dns(host, timeout=5):
103+
"""Resolve forward and reverse DNS for *host*, with timeout."""
104+
forward = []
105+
reverse = []
106+
107+
def _alarm_handler(signum, frame):
108+
raise TimeoutError
109+
110+
old_handler = signal.signal(signal.SIGALRM, _alarm_handler)
111+
try:
112+
signal.alarm(timeout)
113+
try:
114+
infos = socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
115+
forward = sorted({info[4][0] for info in infos})
116+
except (socket.gaierror, TimeoutError):
117+
pass
118+
for addr in forward:
119+
try:
120+
hostname, _, _ = socket.gethostbyaddr(addr)
121+
reverse.append(hostname)
122+
except (socket.herror, socket.gaierror, TimeoutError):
123+
continue
124+
finally:
125+
signal.alarm(0)
126+
signal.signal(signal.SIGALRM, old_handler)
127+
return forward, sorted(set(reverse))
128+
129+
130+
def _format_banner(banner_data):
131+
"""Return (clean_text, raw_display) from a banner dict."""
132+
text = banner_data.get("text", "")
133+
raw_hex = banner_data.get("raw_hex", "")
134+
if _HAS_WCWIDTH and text:
135+
clean = strip_sequences(text)
136+
else:
137+
clean = text
138+
if _HAS_WCWIDTH and text:
139+
parts = []
140+
for seq in iter_sequences(text):
141+
parts.append(repr(seq))
142+
raw_display = " ".join(parts)
143+
else:
144+
raw_display = raw_hex
145+
return clean, raw_display
146+
147+
148+
def _print_server_context(session_data):
149+
"""Print server fingerprint details for moderation context."""
150+
for banner_key, banner_label in (
151+
("banner_before_return", "pre-return"),
152+
("banner_after_return", "post-return"),
153+
):
154+
banner = session_data.get(banner_key, {})
155+
if not banner:
156+
continue
157+
clean, raw_display = _format_banner(banner)
158+
if clean:
159+
print(f" banner ({banner_label}, clean):")
160+
for line in clean.splitlines():
161+
print(f" {line}")
162+
print()
163+
if raw_display:
164+
print(f" banner ({banner_label}, raw):")
165+
for i in range(0, len(raw_display), 76):
166+
print(f" {raw_display[i:i + 76]}")
167+
print()
168+
169+
host = session_data.get("host", "")
170+
port = session_data.get("port", "")
171+
if host:
172+
host_str = f"{host}:{port}" if port else host
173+
print(f" host: {host_str}")
174+
forward, reverse = _resolve_dns(host)
175+
if forward:
176+
print(f" forward DNS: {', '.join(forward)}")
177+
if reverse:
178+
print(f" reverse DNS: {', '.join(reverse)}")
179+
180+
82181
def _print_paired(paired_hashes, label, names):
83182
"""Print paired fingerprint hashes with names when known."""
84183
if not paired_hashes:
@@ -131,10 +230,11 @@ def _scan(data_dir, names, revise=False):
131230
labels.setdefault(h, probe_key.split("-", maxsplit=1)[0])
132231
fp_data.setdefault(h, data.get(probe_key, {}).get("fingerprint-data", {}))
133232
sessions.setdefault(h, data.get(probe_key, {}).get("session_data", {}))
134-
other = "terminal-probe" if probe_key == "telnet-probe" else "telnet-probe"
135-
other_h = data.get(other, {}).get("fingerprint")
136-
if other_h and other_h != _UNKNOWN:
137-
paired[h].add(other_h)
233+
if probe_key in ("telnet-probe", "terminal-probe"):
234+
other = "terminal-probe" if probe_key == "telnet-probe" else "telnet-probe"
235+
other_h = data.get(other, {}).get("fingerprint")
236+
if other_h and other_h != _UNKNOWN:
237+
paired[h].add(other_h)
138238
look = rev_key if revise else sug_key
139239
if look in file_sug:
140240
suggestions[h].append(file_sug[look])
@@ -169,6 +269,8 @@ def _review(entries, names):
169269
_print_telnet_context(session_data)
170270
elif label == "terminal" and session_data:
171271
_print_terminal_context(session_data)
272+
elif label == "server" and session_data:
273+
_print_server_context(session_data)
172274
_print_paired(paired_hashes, label, names)
173275

174276
default = ""
@@ -203,9 +305,22 @@ def _review(entries, names):
203305
def _relocate(data_dir):
204306
"""Move misplaced JSON files to match their internal fingerprint hashes."""
205307
client_base = data_dir / "client"
308+
server_base = data_dir / "server"
206309
moved = 0
207310
stale = set()
208311
for path, data in _iter_files(data_dir):
312+
sh = data.get("server-probe", {}).get("fingerprint")
313+
if sh:
314+
if path.parent.name == sh:
315+
continue
316+
target = server_base / sh / path.name
317+
if target.exists():
318+
continue
319+
target.parent.mkdir(parents=True, exist_ok=True)
320+
os.rename(path, target)
321+
moved += 1
322+
stale.add(path.parent)
323+
continue
209324
th = data.get("telnet-probe", {}).get("fingerprint")
210325
tmh = data.get("terminal-probe", {}).get("fingerprint", _UNKNOWN)
211326
if not th:
@@ -232,8 +347,11 @@ def _relocate(data_dir):
232347
def _prune(data_dir, names):
233348
"""Remove named hashes that have no data files."""
234349
hashes = set()
235-
for path, _ in _iter_files(data_dir):
236-
hashes.update({path.parent.parent.name, path.parent.name})
350+
for _path, data in _iter_files(data_dir):
351+
for probe_key in _PROBES:
352+
h = data.get(probe_key, {}).get("fingerprint")
353+
if h and h != _UNKNOWN:
354+
hashes.add(h)
237355
orphaned = {h: n for h, n in names.items() if h not in hashes}
238356
if not orphaned:
239357
return False
@@ -253,27 +371,49 @@ def _prune(data_dir, names):
253371
return True
254372

255373

374+
def _get_argument_parser():
375+
"""Build argument parser for ``moderate_fingerprints`` CLI."""
376+
parser = argparse.ArgumentParser(
377+
description="Moderate fingerprint name suggestions",
378+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
379+
)
380+
parser.add_argument(
381+
"--data-dir",
382+
default=os.environ.get("TELNETLIB3_DATA_DIR"),
383+
help="directory for fingerprint data (default: $TELNETLIB3_DATA_DIR)",
384+
)
385+
parser.add_argument(
386+
"--check-revise", action="store_true", help="review already-named fingerprints for revision"
387+
)
388+
parser.add_argument(
389+
"--no-prune",
390+
action="store_true",
391+
help="skip pruning orphaned hashes from fingerprint_names.json",
392+
)
393+
return parser
394+
395+
256396
def main():
257397
"""CLI entry point for moderating fingerprint name suggestions."""
258-
data_dir_env = os.environ.get("TELNETLIB3_DATA_DIR")
259-
if not data_dir_env:
260-
print("Error: TELNETLIB3_DATA_DIR not set", file=sys.stderr)
398+
args = _get_argument_parser().parse_args()
399+
400+
if not args.data_dir:
401+
print("Error: --data-dir or $TELNETLIB3_DATA_DIR required", file=sys.stderr)
261402
sys.exit(1)
262-
data_dir = Path(data_dir_env)
403+
data_dir = Path(args.data_dir)
263404
if not data_dir.exists():
264405
print(f"Error: {data_dir} does not exist", file=sys.stderr)
265406
sys.exit(1)
266407

267-
revise = "--check-revise" in sys.argv
268408
relocated = _relocate(data_dir)
269409
if relocated:
270410
print(f"Relocated {relocated} file(s).\n")
271411

272412
names = _load_names(data_dir)
273-
if "--no-prune" not in sys.argv and _prune(data_dir, names):
413+
if not args.no_prune and _prune(data_dir, names):
274414
_save_names(data_dir, names)
275415

276-
entries = _scan(data_dir, names, revise)
416+
entries = _scan(data_dir, names, args.check_revise)
277417
if entries and _review(entries, names):
278418
_save_names(data_dir, names)
279419
elif not entries:

0 commit comments

Comments
 (0)