77import sys
88import json
99import shutil
10+ import signal
11+ import socket
12+ import argparse
1013import subprocess
1114import collections
1215from 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
2335def _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
3555def _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+
82181def _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):
203305def _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):
232347def _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+
256396def 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