"""Small Linux UDP trace using the socket error queue; no raw socket/root needed.""" import ipaddress import socket import struct import sys import time import select import re import shutil import errno from .geo import GeoLocator from .console import clean def finish_trace(summary,state,error=None): if summary is not None:summary.update(state=state,error_code=error) return 0 if state=='reached' else 1 def render_hop(row): address=row.get('ip') or '*' rtt=f"{row['latency_ms']:.3f} ms" if row.get('latency_ms') is not None else '---' asn='AS'+str(row['asn']) if row.get('asn') else 'AS?' return f"{row['hop']:2d} {address:<39} {rtt:>12} {clean(row.get('location') or '---')} {asn} {clean(row.get('operator') or '---')} [{row['state']}]" def parse_traceroute_hop(text, ttl, target): """Parse numeric Linux traceroute -n -q 1 output; retain explicit errors.""" for line in text.splitlines(): match = re.match(r'^\s*(\d+)\s+(.*)', line) if not match or int(match[1]) != ttl: continue rest=match[2] if rest.strip()=='*': return {'hop':ttl,'ip':None,'latency_ms':None,'state':'timeout','probes_sent':1,'responses':0} fields=rest.split() try: address=str(ipaddress.ip_address(fields[0])) rtt=float(fields[1]) if not 0<=rtt<=60000 or fields[2]!='ms':continue except (ValueError,IndexError):continue unreachable=any(token.startswith('!') for token in fields[3:]) return {'hop':ttl,'ip':address,'latency_ms':rtt,'probes_sent':1,'responses':1, 'state':'unreachable' if unreachable else 'reached' if address==str(target) else 'reply'} return None def trace_method(target, emit=print, *, method='udp', tcp_port=443, hops=30, wait=1.0, lang='zh', geo=True, records=None, on_result=None, deadline=None,summary=None): """Native UDP or fixed-system Linux traceroute ICMP/TCP, without elevation.""" if method=='udp': return trace(target,emit,hops,wait,lang,geo,records,on_result,deadline,summary) if method not in ('icmp','tcp') or not 1<=hops<=64 or not 1<=tcp_port<=65535 or not 0=deadline: emit('Route observation time budget exhausted (90s)',flush=True) return finish_trace(summary,'partial' if records else 'not_reached','TIME_BUDGET_EXHAUSTED') sock.setsockopt(protocol,socket.IPV6_UNICAST_HOPS if v6 else socket.IP_TTL,ttl) start=time.monotonic() endpoint=(str(address),33434+ttl,0,0) if v6 else (str(address),33434+ttl) sock.sendto(b'VPSMaster',endpoint) found=False while time.monotonic()-start < wait: try: _, controls, _, destination=sock.recvmsg(512,512,socket.MSG_ERRQUEUE) except BlockingIOError: remaining=wait-(time.monotonic()-start) if remaining>0: select.select([sock],[],[sock],remaining) continue if not destination or ipaddress.ip_address(destination[0]) != address or destination[1] != 33434+ttl: continue # Ignore late errors from previous TTL probes. for level,kind,data in controls: if level != protocol or kind != recverr or len(data)<(44 if v6 else 24): continue _,origin,icmp_type,icmp_code,_,_,_=struct.unpack('=IBBBBII',data[:16]) if origin != (3 if v6 else 2): continue if icmp_type not in ((1,3) if v6 else (3,11)): continue hop=socket.inet_ntop(family,data[24:40] if v6 else data[20:24]) elapsed=(time.monotonic()-start)*1000 detail=locator.details(hop) location=clean(detail['location']) reached=(icmp_type==1 and icmp_code==4 if v6 else icmp_type==3 and icmp_code==3) and ipaddress.ip_address(hop)==address row={'hop':ttl,'ip':hop,'latency_ms':round(elapsed,3),'location':location, 'probes_sent':1,'responses':1,'response_loss_percent':0.0,'statistics_scope':'one_probe_this_ttl_not_forwarding_loss', 'icmp_type':icmp_type,'icmp_code':icmp_code,'state':'reached' if reached else 'reply',**detail} if records is not None:records.append(row) emit(render_hop(row),flush=True) found=True if on_result is not None: on_result() if reached: return finish_trace(summary,'reached') if icmp_type==(1 if v6 else 3): emit(f' ICMP 不可达(代码 {icmp_code})' if zh else f' ICMP unreachable (code {icmp_code})',flush=True) return finish_trace(summary,'not_reached','ICMP_UNREACHABLE') if found: break if not found: emit(f'{ttl:2d} {"*":<39} {"---":>8} --- [reply 0/1; timeout 1/1]',flush=True) if records is not None: records.append({'hop':ttl,'ip':None,'latency_ms':None,'location':None,'state':'timeout', 'probes_sent':1,'responses':0,'response_loss_percent':100.0,'statistics_scope':'one_probe_this_ttl_not_forwarding_loss'}) if on_result is not None: on_result() except (OSError,AttributeError) as error: emit('原生 UDP 追踪不可用或未收到允许的响应' if zh else 'Native UDP trace unavailable or blocked',flush=True) code='PERMISSION_DENIED' if getattr(error,'errno',None) in (errno.EPERM,errno.EACCES) else 'NETWORK_UNREACHABLE' if getattr(error,'errno',None) in (errno.ENETUNREACH,errno.EHOSTUNREACH) else 'UDP_TRACE_UNAVAILABLE' return finish_trace(summary,'permission_denied' if code=='PERMISSION_DENIED' else 'network_unavailable' if code=='NETWORK_UNREACHABLE' else 'unknown',code) return finish_trace(summary,'partial' if records and any(r.get('responses') for r in records) else 'not_reached','MAX_HOPS_REACHED') def summarize_hops(records): """Statistics over actually sent TTL probes, never forwarding-loss claims.""" import statistics grouped={} for row in records: ttl=row['hop'] item=grouped.setdefault(ttl,{'hop':ttl,'probes_sent':0,'responses':0,'ips':set(),'rtts':[]}) item['probes_sent']+=row.get('probes_sent',1) item['responses']+=row.get('responses',1 if row.get('ip') else 0) if row.get('ip'):item['ips'].add(row['ip']) if row.get('latency_ms') is not None:item['rtts'].append(row['latency_ms']) result=[] for ttl,item in sorted(grouped.items()): sent=item['probes_sent'];received=item['responses'] result.append({'hop':ttl,'probes_sent':sent,'responses':received,'timeouts':sent-received, 'response_loss_percent':round(100*(sent-received)/sent,2) if sent else None, 'median_rtt_ms':statistics.median(item['rtts']) if item['rtts'] else None, 'ips':sorted(item['ips']), 'scope':'router_response_loss_only_not_end_to_end_forwarding_loss'}) return result