Files
VPSMaster/proxydoctor/connectivity.py

204 lines
10 KiB
Python

"""Family-specific bounded DNS/TCP/TLS observations, without HTTP content."""
import errno
import ipaddress
import json
import socket
import ssl
import subprocess
import sys
import time
from .reports import utc_now
TARGETS = (
('Cloudflare','one.one.one.one','1.1.1.1'),
('Cloudflare','one.one.one.one','2606:4700:4700::1111'),
('Google DNS','dns.google','8.8.8.8'),
('Google DNS','dns.google','2001:4860:4860::8888'),
)
RESOLVER = '''import json,socket,sys
family=int(sys.argv[1])
try:
answers=socket.getaddrinfo('example.com',443,family=family,type=socket.SOCK_STREAM)
print(json.dumps({'addresses':sorted(set(item[4][0] for item in answers))}))
except socket.gaierror as error:
print(json.dumps({'error':error.errno}))
'''
def dns_probe(version, timeout=2):
if version not in (4,6) or not 0 < timeout <= 5:
raise ValueError('Invalid DNS probe limits')
family = socket.AF_INET if version == 4 else socket.AF_INET6
row = {'family': version, 'target': 'example.com', 'method': 'system_getaddrinfo',
'observed_at': utc_now(), 'status': 'unknown', 'error_code': None}
start = time.monotonic()
try:
process = subprocess.run([sys.executable,'-I','-B','-c',RESOLVER,str(int(family))],
capture_output=True, text=True, timeout=timeout, check=False)
if process.returncode or len(process.stdout) > 16384:
row['error_code'] = 'RESOLVER_PROCESS_ERROR'
else:
payload = json.loads(process.stdout)
code = payload.get('error')
if code is not None:
row['error_code'] = ('DNS_TEMPORARY_ERROR' if code == socket.EAI_AGAIN else
'DNS_NO_ADDRESS' if code == socket.EAI_NONAME else 'DNS_RESOLUTION_ERROR')
else:
addresses = payload.get('addresses')
if not isinstance(addresses,list) or not addresses or any(ipaddress.ip_address(value).version != version for value in addresses):
row['error_code'] = 'DNS_INVALID_RESPONSE'
else:
row.update(status='success',addresses=addresses)
except subprocess.TimeoutExpired:
row['error_code'] = 'DNS_TIMEOUT'
except (OSError, ValueError, TypeError, AttributeError):
row['error_code'] = 'DNS_PROBE_ERROR'
row['elapsed_ms'] = round((time.monotonic()-start)*1000,3)
row['timing_scope'] = 'resolver_process_including_startup_and_cache'
return row
def transport_probe(label, hostname, address, timeout=4):
ip = ipaddress.ip_address(address)
if not 0 < timeout <= 10:
raise ValueError('Invalid transport timeout')
row = {'target': label, 'hostname': hostname, 'address': address, 'family': ip.version,
'port': 443, 'observed_at': utc_now(), 'method': 'numeric_tcp_then_verified_tls',
'tcp': {'status':'unknown'}, 'tls': {'status':'skipped','error_code':'TCP_NOT_CONNECTED'}}
started = time.monotonic()
deadline = started + timeout
stage = 'tcp'
try:
# Numeric addresses only: DNS cannot contaminate this TCP observation.
with socket.create_connection((address,443),timeout=timeout) as raw:
row['tcp'] = {'status':'success','elapsed_ms':round((time.monotonic()-started)*1000,3)}
stage = 'tls'
context = ssl.create_default_context()
remaining = deadline-time.monotonic()
if remaining <= 0:
raise TimeoutError
raw.settimeout(remaining)
tls_start = time.monotonic()
with context.wrap_socket(raw,server_hostname=hostname) as secure:
row['tls'] = {'status':'success','elapsed_ms':round((time.monotonic()-tls_start)*1000,3),
'version': secure.version(), 'certificate_verified': True}
except ssl.SSLCertVerificationError:
row['tls'] = {'status':'unknown','error_code':'TLS_CERTIFICATE_ERROR'}
except (TimeoutError, socket.timeout):
row[stage] = {'status':'unknown','error_code':stage.upper()+'_TIMEOUT'}
except ssl.SSLError:
row['tls'] = {'status':'unknown','error_code':'TLS_HANDSHAKE_ERROR'}
except OSError as error:
reason = {errno.ENETUNREACH:'NETWORK_UNREACHABLE',errno.EHOSTUNREACH:'HOST_UNREACHABLE',
errno.ECONNREFUSED:'CONNECTION_REFUSED',errno.EAFNOSUPPORT:'FAMILY_UNSUPPORTED'}.get(error.errno,'SOCKET_ERROR')
row[stage] = {'status':'unknown','error_code':reason}
row['elapsed_ms'] = round((time.monotonic()-started)*1000,3)
return row
def collect(report=None, on_result=None, *, rounds=2, dns=dns_probe, transport=transport_probe):
if not 1 <= rounds <= 3:
raise ValueError('DNS rounds must be 1..3')
report = {} if report is None else report
report.update(schema_version=1, observed_at=utc_now(), dns=[], transports=[], dns_summary={},
limitations=['Only outbound paths to the named targets were checked.',
'DNS timings include process startup and system resolver caching.',
'No UDP, PMTU, inbound reachability or media playback was tested.'])
def completed():
if on_result is not None:
on_result()
for version in (4,6):
rows = []
for _ in range(rounds):
try:
row = dns(version)
except Exception:
row = {'family':version,'status':'unknown','error_code':'DNS_PROBE_ERROR','observed_at':utc_now()}
rows.append(row)
report['dns'].append(row)
completed()
count = sum(row['status'] == 'success' for row in rows)
report['dns_summary'][str(version)] = {'attempted':rounds,'succeeded':count,
'success_ratio':count/rounds}
for label, hostname, address in TARGETS:
try:
row = transport(label,hostname,address)
except Exception:
row = {'target':label,'family':ipaddress.ip_address(address).version,
'tcp':{'status':'unknown','error_code':'PROBE_ERROR'},'tls':{'status':'skipped'}}
report['transports'].append(row)
completed()
report['diagnostics'] = []
for version in (4,6):
transports = [row for row in report['transports'] if row['family'] == version]
tcp_ok = any(row['tcp']['status'] == 'success' for row in transports)
dns_ok = report['dns_summary'][str(version)]['succeeded'] > 0
code = ('DNS_FAILED_NUMERIC_TCP_WORKS' if tcp_ok and not dns_ok else
'DNS_WORKS_TCP_UNCONFIRMED' if dns_ok and not tcp_ok else
'TARGETS_PARTIALLY_VERIFIED' if tcp_ok else 'CONNECTIVITY_UNCONFIRMED')
report['diagnostics'].append({'family':version,'code':code})
report['status'] = 'success' if all(row['status']=='success' for row in report['dns']) and all(
row['tcp']['status']=='success' and row['tls']['status']=='success' for row in report['transports']) else 'partial'
completed()
return report
def render_row(row, out, kind):
tr = out.text
labels = {
'DNS_TIMEOUT': ('DNS 超时','DNS timed out'),
'DNS_NO_ADDRESS': ('未取得该协议族地址','No address for this family'),
'DNS_TEMPORARY_ERROR': ('解析器暂时失败','Temporary resolver error'),
'TLS_CERTIFICATE_ERROR': ('证书验证失败;检查时间及信任链','Certificate rejected; check clock and trust chain'),
'TLS_HANDSHAKE_ERROR': ('TLS 握手未完成','TLS handshake failed'),
'TCP_NOT_CONNECTED': ('TCP 未连接,跳过 TLS','TCP unavailable; TLS skipped'),
'NETWORK_UNREACHABLE': ('系统报告网络不可达','System reports network unreachable'),
'CONNECTION_REFUSED': ('连接被拒绝','Connection refused'),
'TCP_TIMEOUT': ('TCP 超时','TCP timed out'),
'TLS_TIMEOUT': ('TLS 超时','TLS timed out'),
}
def error_label(result):
code = result.get('error_code','UNKNOWN')
return tr(*labels[code])+f' ({code})' if code in labels else code
family = 'IPv'+str(row['family'])
if kind == 'dns':
out.row(f'{family} DNS',f"{row['elapsed_ms']:g} ms" if row['status']=='success' else error_label(row),
'ok' if row['status']=='success' else 'unknown')
else:
for stage in ('tcp','tls'):
result = row[stage]
out.row(f"{family} {row['target']} {stage.upper()}",
f"{result['elapsed_ms']:g} ms" if result['status']=='success' else error_label(result),
'ok' if result['status']=='success' else 'unknown')
def run_console(lang='zh', color='auto', report=None, on_result=None, *, out=None):
from .console import Console
out = out if out is not None else Console(lang,color)
report = {} if report is None else report
seen_dns = seen_transport = 0
def progress():
nonlocal seen_dns, seen_transport
for row in report['dns'][seen_dns:]:
render_row(row,out,'dns')
for row in report['transports'][seen_transport:]:
render_row(row,out,'transport')
seen_dns, seen_transport = len(report['dns']), len(report['transports'])
if on_result is not None:
on_result()
collect(report, progress)
for version, summary in report['dns_summary'].items():
out.row(f'IPv{version} DNS',f"{summary['succeeded']}/{summary['attempted']} "+out.text('成功(仅本次样本)','successful (current sample only)'))
for diagnosis in report['diagnostics']:
code = diagnosis['code']
note = {
'DNS_FAILED_NUMERIC_TCP_WORKS': ('解析未成功但数字 IP 可连接;先排查解析器','DNS failed but numeric TCP worked; inspect resolver'),
'DNS_WORKS_TCP_UNCONFIRMED': ('解析有结果,目标 TCP 未确认;检查路由及目标','DNS answered but target TCP is unconfirmed; inspect routes and targets'),
'TARGETS_PARTIALLY_VERIFIED': ('部分目标 TCP 已确认;TLS 结果单独看','Some target TCP paths verified; inspect TLS separately'),
'CONNECTIVITY_UNCONFIRMED': ('连接尚未确认','Connectivity unconfirmed'),
}[code]
out.row(f"IPv{diagnosis['family']} "+out.text('提示','Hint'),out.text(*note),'unknown')
out.row(out.text('检测范围','Scope'),out.text('出站 DNS/TCP/TLS 检测','Outbound DNS/TCP/TLS checks'),'unknown')
return 0 if report['status']=='success' else 1