Harden trace evidence, terminal rendering and interrupted session entry

This commit is contained in:
ProxyDoctor Checkpoint committed 2026-11-01 13:02:29 -08:00
1 parent 9804b73cf0
commit 0fa26890f1
18 files changed
+272 -54

No files matched your search

+6 -1
View File
@@ -1,4 +1,9 @@
# 当前状态:1.0.0b1 · P0 修复候选
# 当前状态:1.0.0b1 · 最终加固候选
本轮新增路由错误分类、ASN/运营商与来源、逐轮结构化结果、窄终端适配和控制字符过滤。
曾完成回归 281 项通过、1 项 Windows 权限跳过;之后修复实测发现的初次检查点中断清理边界,并新增回归,最终计数见包外报告。首次三项失败为校验清单过期,重建后通过,未删除或放宽测试。
本轮实际验收以包外 FINAL_TEST_REPORT.md、BLACKBOX_TEST_RESULTS.json 中同一 ZIP SHA256 为准。
以下 277 项与上轮虚拟机结果是历史,不能代替本轮验收。未将完整三网、海外 VPS、ARM、新装 22 Server 标为完成。
2026-11-01;非最终版本,未推送、合并或公开发布。
+19
View File
@@ -0,0 +1,19 @@
# Known limitations — 1.0 public Beta
- Targets: Ubuntu22.04+ / Python3.10+. Available labs: existing Ubuntu22 Desktop with
fresh HOME, Ubuntu24 Server. No new Ubuntu22 Server, overseas VPS or ARM acceptance implied.
- Kernel text consoles default to English. Explicit Chinese needs suitable fonts.
- Missing commands, permissions, quotas, certificates or filtering can yield unknown/partial.
- One response per TTL is router-response evidence, not forwarding loss. IP geography
is an estimate. ASN/operator can be unavailable; trace enrichment caps at 12 public lookups.
- No proof of dedicated hardware, complete reputation, mail deliverability, playback,
full China routes, commercial BGP relationships or line capacity.
- China bandwidth candidates are optional, not a validated complete mainland set.
Hong Kong never substitutes for mainland Mobile. No remote scripts execute.
- Default checks do not change SSH, firewall, DNS or routes. Installation creates a
user venv; reports create local files; opt-in bandwidth sends bounded test payloads.
- Sharing redacts known patterns, not arbitrary text. Review exports; raw journals,
inventories, credentials, snapshots and backups must remain private.
- The manifest detects file changes, not publisher authenticity. Verify the ZIP
digest through a trusted independent channel before installation.
- Continuation files support manual restart of work, not automatic Codex scheduling.
+17 -13
View File
@@ -11,14 +11,17 @@
"ARCHITECTURE.md": "2d0167efe06df1eb10f0386b3a48c743fcd555a57e7a5247f1af24e6d2524701",
"CHANGELOG.md": "98fc7cb3d1ba9f39680f5cbda1511340af05af35f8d3b12eaf2d213bce573e88",
"CONTRIBUTING.md": "45fa883a73cd267a732bfd99e542f736c975a1e11ac9075c127262a4500de902",
"CURRENT_STATUS.md": "735e4bdef596fe4f25780225b30e975998b4cd1061aa091da673d07d8c85c5e0",
"CURRENT_STATUS.md": "208d863c73e3231a3be23380b7ba5a6885e0dd29f7e42c8b59331fbdd9b7ece9",
"DEVELOPMENT_BRIEF.zh.md": "c80df4f88d60decdacb29864d48760a3517f1016bdb0ba784749365116affc6e",
"IPQUALITY_COVERAGE.md": "342c4919b0ba7f05699f16526e8d0153b91e7e1c38876451019cd9ee5f1613c7",
"KNOWN_LIMITATIONS.md": "a14f8eecb8ad2d13c06ee5c1c10edb13f17aebbf3d1119e7c3b00aabe6fc3823",
"OPEN_SOURCE_REFERENCES.md": "f75ae885e4f31902a77e3eea90cfee3a3a0b38c0772ba70dc8935448fe483051",
"OPEN_SOURCE_RESEARCH.md": "5ae3b119e93a0f7164a50bfd0e65f3c811285c04d626c25d02e950bd9b75d432",
"PROBLEM_BACKLOG.md": "a65378b9572aa8e703bbf94df26bd0b8e80b36b1d0249962ed5b1ccfbc309419",
"PROBLEM_BACKLOG.md": "4d873fd0f3af48905a400c3bbfbbfc4d6ada5f7590bbdbb1ed46d430e8feb7fb",
"PUBLIC_BETA_CHANGE_REPORT.md": "abe72da4c576e02089e4e564c5f88fd81e5c3034347ca48c2bcb981d0f0dcca4",
"README.en.md": "5cd2e7d51221b8290e4c81ca4bbd0d6eb5ff9d38ee9b5e50e8ed43693ca73d64",
"README.md": "be116592cf5814c5a3ec9521f7b2fb691ca2c4bde62f4bfd649722086d4e1452",
"README.en.md": "04776b85400e347559b2dfc90e8ef322c02133e3d7d8de483cca2e5dab861a1b",
"README.md": "9e2dc16f3bef869b7182965d6843fc0d5b3696a4d04c3eb44e85bfd52272087b",
"RELEASE_NOTES.md": "4b168c3f59be6bc6c76818f5de26732dfd336e33cd709008f0c40f2493f171e7",
"SECURITY.md": "5b55903153973d4413d53b4715d71df677df20cde4506bac5bc866778b654838",
"TEST_PLAN.md": "6036d77b05958e9c088aa9605d3b78986ab3b7ded42abcf65f53118099d9e490",
"THIRD_PARTY_NOTICES.md": "b1f24279e2870c4917b85b220a42d1ae31b6e39452cd1aa8dcb55425b0d6a6d1",
@@ -27,12 +30,12 @@
"proxydoctor/__main__.py": "f21b83731acbaa16519e17fa795601ccd3543400cd5c59bb40098a488c225d84",
"proxydoctor/bandwidth.py": "199a350483c53467cc341b521951e7a533ab04d5655dd8ceb2da94ad85653d34",
"proxydoctor/bgp.py": "4c9fea00e616058700f73335fe1995764b85d610a89b22936b4e5dd9f563c0c6",
"proxydoctor/carriers.py": "3838b06354e68f57aa514ba4570be087489582821e530a4f2948a15598712295",
"proxydoctor/carriers.py": "11506fef02973552ab74367f3c1f91c7a7ec484ac574c71eada6ab82fc5448aa",
"proxydoctor/checks.py": "f010c09c834af2f4ac48128113bb5aebef9d1fb0c796891623d02232ccf09e31",
"proxydoctor/cli.py": "0a53081310bdc76cc57fc7ef75c645a0f8efee04fa03c4a463ca75b7ddd7e143",
"proxydoctor/cli.py": "8f99e27e97a2551b40ab45f06c786d06ba8be53fd5a9fbd744cc431615717f2c",
"proxydoctor/connectivity.py": "2b6d4cfe69271ad582c3b0eb81bc69d38a9395c80cc12df2804bd01854f22385",
"proxydoctor/console.py": "c3de3fd95509a701df296c5d9c7bc167fe39e7b557b505efe477180283508948",
"proxydoctor/geo.py": "9547a5e7da6d92d62df0fd5977d39384fcb5c745fac7efed93d283ae88074643",
"proxydoctor/console.py": "1cc0ab4b08aa0801cb8115b4ce205533f0e6436ebe699fdbcd0f9272a327bd5d",
"proxydoctor/geo.py": "e4dde1452488743a0fc9a74e65bca65cad894ee357bc09c1d2b6dbdc2f0cfd04",
"proxydoctor/i18n.py": "d9f619c0b0e85ef7295aec39bf1a94b1c43d7f19d5fd0fe86f438e84d05f89b1",
"proxydoctor/ipquality.py": "4dd1fc1421d11d03d8c803626393ca02d7be81527d4984da57c9ef2140c395bb",
"proxydoctor/mailquality.py": "ae1ad2e071bd7038b6ea7fbcbd63b7128eac9e5477f79f43c22172d6a5bb5071",
@@ -42,13 +45,13 @@
"proxydoctor/reporting.py": "2689e39fe1c1f4f07ac43e3b7b564de34cb36d20e56669ab30c20923f27c727d",
"proxydoctor/reports.py": "09bd0952c89397ef601a3421e563bdad1e74b17d07976dfcb4066a0353cea4ba",
"proxydoctor/resources.py": "b2b7355ef8f09a16d28956e292dfb848c2ddc927c3773730c437cf53de4a09a4",
"proxydoctor/routes.py": "9bec6db8ce78ffa34c41931f7d388e9ac6d59ce52fed5d442371efe844d426ae",
"proxydoctor/routes.py": "e73893f170c1ecb578d350843f2789be05e6ed616704e602c89e0924ec1c1c07",
"proxydoctor/runner.py": "84462cb56bf335140a3aec08ace9c80dd745a9aeea99be618ac4483cb3430c8e",
"proxydoctor/schema.py": "f80098d2dbf0334d460e47754a429a7449532fdd6aa69420ed275edebd5f8830",
"proxydoctor/schema.py": "6ae0e99c696fae778e88f96132142ac8eecb51a6a5df91ba38b816895aa1ca88",
"proxydoctor/security.py": "363327e43ab13dc8ebb9bb88f49fd14cea4aedca0952e9733159f99a195bb019",
"proxydoctor/session.py": "e72383becc82015b4a2ea5a8a6aa535a6b6e6bba90b94f29117346d9a9b0acf7",
"proxydoctor/suite.py": "e0f608e71669c7dc92a659ee0a8f083569a640cd83aeaa15077308da68b73c49",
"proxydoctor/worker.py": "8dcabf06c2604f846cc8fe5b09a392ff782c30fc7145f6befeef733133ac4eb3",
"proxydoctor/session.py": "3302f8aa1f031ca13c863ad0b464d94b11a41ee944e97cdb2f4d6ad2e7990737",
"proxydoctor/suite.py": "68519399071bd6c700061aa86c10d05b88a87274d320c61ab846b79ea4b489da",
"proxydoctor/worker.py": "c4745ba21aa68d4ce57c9bb8727841f8839d1c1c2d820e0e8e7ff071c88b0e06",
"tests/conftest.py": "9ba6f678d6297f2dc85f42121790246cd86b120dd52e537ee09d4c19f2cdf1b9",
"tests/test_alpha_quality.py": "a833be4ec7f0600383495b838ea1e95f50112003d47b2cc2cebbec50d0eba706",
"tests/test_bandwidth_sources.py": "c9e41f7f3d25078feef335a549fb8a3229662d552b552f0579369ae61cfc659b",
@@ -56,6 +59,7 @@
"tests/test_checks.py": "4ff2ff8562a13635d24cf6a29dd7063578bcc1a202840aeb76c7619c06cacfb3",
"tests/test_connectivity.py": "e4cea940bf3783e312f7acf28b80c7fc709579e3809a415a5822b05431d6fde1",
"tests/test_console_geo.py": "24d91b4abf292f666fde27244099bab7d52afff99b835c2fda26bd73a86aba03",
"tests/test_final_hardening.py": "75350b7d1e33082c6865a413851a5fa27ef3d339d6a22eeea1a0304c55ed8f07",
"tests/test_i18n.py": "48e1615334c1c1b19a872bf11067e7e38f78ece60eb777d3cc4ad7661cef1c12",
"tests/test_ip_panel.py": "817b27b6eb96e91b370418fe1b7e4625253d77091e87e8bf55e8077781044570",
"tests/test_mailquality.py": "87907932a22a423ec8134bb60ebf29523019b4c91ae774ab2c2fb65dd5113c44",
+22
View File
@@ -0,0 +1,22 @@
# References and licensing
The independently implemented stdlib runtime has no third-party Python dependency.
No new third-party code, database or binary was copied during final hardening.
Full references and retained MIT notices: THIRD_PARTY_NOTICES.md.
Reviewed 2026-11-01:
- https://github.com/spiritLHLS/ecs — module/workflow reference, MIT; no copied code.
- https://github.com/oneclickvirt/ecs — workflow reference; no copied code.
- https://github.com/nxtrace/NTrace-core/blob/main/fast_trace/basic.go — public trace
target names. GPL-3.0: https://github.com/nxtrace/NTrace-core/blob/main/LICENSE .
No implementation/database bundled. Names alone do not authorize arbitrary traffic.
- https://github.com/CleanIP/sh.cd — catalogue reference; upstream MIT notices retained.
Availability, carrier identity and service terms require independent validation.
- https://stat.ripe.net/docs/data-api/ripestat-data-api — read-only BGP observations.
- https://ipwhois.io/documentation — optional route geography/ASN/operator estimates.
NodeQuality, IPQuality, NetQuality, RegionRestrictionCheck and BestTrace inform product
coverage; implementations/binaries are not bundled. Acknowledgement does not grant
permission to copy. Future reuse needs a new license review. API and public test
service terms/limits apply independently of source-code licenses.
+1
View File
@@ -1,3 +1,4 @@
> 最终加固:路由错误分类、跳点 ASN/运营商来源、窄终端适配已有回归。环境覆盖与结论以同 SHA256 的 FINAL_TEST_REPORT.md 为准。完整大陆三网、海外 VPS、ARM、新装 22 Server 尚未完成。
> 历史 Alpha/问题状态记录:当前 1.0 公测候选请以 CURRENT_STATUS.md 和 PUBLIC_BETA_CHANGE_REPORT.md 为准;新增代码不等于已完成实测。
> 最新Alpha优先项状态见 [ALPHA_TASK_STATUS.md](ALPHA_TASK_STATUS.md)。以下包含本轮前历史统计,不能作为最新发布验收结论。
+6
View File
@@ -6,6 +6,12 @@ Read-only Python VPS diagnostics; internal package/command: proxydoctor. **Publi
## Install
Final hardening: route outcomes retain permission/dependency/network errors. ICMP/TCP
permissions depend on system traceroute; no automatic elevation. Optional `--geo`
adds estimated location, ASN/operator, provider and time, capped at 12 public lookups.
Interactive frames adapt to narrow terminals. Exact acceptance scope is recorded in
the separate report matching the ZIP SHA256; overseas VPS results are not implied.
Requires Linux, Python 3.10+ and standard-library venv. Target: Ubuntu 22.04 and newer. No Python runtime dependencies, sudo, pip or automatic system package installation. Check the ZIP against a trusted SHA256 digest and review install.py first; Python extraction works without unzip.
```bash
+4 -1
View File
@@ -51,7 +51,10 @@ PD="$HOME/.local/share/proxydoctor/1.0.0b1/bin/proxydoctor"
## 三网、路由和 BGP
以下是新增公测能力,尚待本轮 Linux 与海外 VPS 实测验收。
实测覆盖以随包提供、SHA256 一致的验收报告为准;没有海外 VPS 验收证据。
ICMP/TCP 权限取决于系统 traceroute。权限不足显示 `PERMISSION_DENIED`,不自动提权。
可选 `--geo` 提供跳点 ASN、运营商、来源与时间;地理位置是数据库推测,最多查询 12 个公网跳点,遇限额停止。窄交互终端面板会适配窗口宽度。
```bash
# 固定候选的小流量探测,不运行带宽测试
+19
View File
@@ -0,0 +1,19 @@
# ProxyDoctor 1.0.0b1 / v1.0.0-beta.1
Public Beta candidate, not the final release. Publication requires the separate
acceptance report matching the exact ZIP SHA256; source changes alone are not a pass.
- Preserve explicit no-bytecode flags on provided launchers and Python workers.
- Preserve route permission/dependency/network errors in CLI and JSON.
- Add bounded route ASN/operator metadata with provider, time and estimated-geography labels.
- Adapt overview/frame widths to narrow interactive terminals; filter control and bidi characters.
- Preserve cancellation reports and close journals when interrupted during context entry.
- Retain system/resource, UDP route, IP/provider evidence, public media page,
read-only security, opt-in bounded bandwidth and report/recovery capabilities.
ICMP/TCP require system traceroute; permissions vary. No automatic elevation.
China candidates are experimental and default off. Small probes are not capacity
measurements. Media is page reachability only, BGP is RIPE RIS collector evidence.
No CPU/disk benchmark, monitor, proxy deployment or Web UI added.
See KNOWN_LIMITATIONS.md, README.md, README.en.md and THIRD_PARTY_NOTICES.md.
+2 -1
View File
@@ -132,8 +132,9 @@ def return_routes(*,method='udp',family=4,hops=30,geo=False,tcp_port=443,destina
if on_result:on_result()
def emit(line,**kwargs):
if out:out.line(line,'unknown')
row['trace_outcome']={}
code=trace_method(row['target'],emit,method=method,tcp_port=tcp_port,hops=hops,lang=out.lang if out else 'en',
geo=geo,records=row['hops'],on_result=progress,deadline=time.monotonic()+30)
geo=geo,records=row['hops'],on_result=progress,deadline=time.monotonic()+30,summary=row['trace_outcome'])
row['state']='reached' if code==0 else 'incomplete'
row['statistics']=summarize_hops(row['hops'])
if on_result:on_result()
+3 -2
View File
@@ -181,8 +181,9 @@ def _main():
from .routes import trace_method,summarize_hops
data.update(target=str(target),method=args.method,hops=[])
code=trace_method(str(target),lambda line,**kw:out.line(line),method=args.method,tcp_port=args.tcp_port,
hops=args.max_hops,geo=args.geo,lang=lang,records=data['hops'],on_result=scan.checkpoint)
data.update(state='reached' if code==0 else 'incomplete',statistics=summarize_hops(data['hops']))
hops=args.max_hops,geo=args.geo,lang=lang,records=data['hops'],on_result=scan.checkpoint,summary=data)
data.update(statistics=summarize_hops(data['hops']))
out.row(st('追踪结果','Trace result'),data['state']+(' / '+data['error_code'] if data.get('error_code') else ''),'ok' if code==0 else 'unknown')
elif args.command=='return-route':
from .carriers import return_routes
out.line(st('方向:本机 → 中国目标;不包含中国侧到本机的探测','Direction: this host → China targets; reverse probes not included'),'unknown')
+10 -2
View File
@@ -3,6 +3,9 @@ import os
import re
import sys
import unicodedata
import shutil
_UNSAFE = re.compile(r'[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]')
# ANSI SGR codes used by the themed renderer.
_RESET = '\033[0m'
@@ -17,7 +20,7 @@ _BG = {'b_green': '42', 'b_yellow': '43', 'b_red': '41', 'b_cyan': '46'}
def clean(value):
return re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', str(value)).strip()
return _UNSAFE.sub(' ', str(value)).strip()
def _cwidth(ch):
@@ -80,7 +83,7 @@ class Console:
# ---- basic paint ----
def paint(self, value, status='ok', preserve_space=False):
value = re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', str(value)) if preserve_space else clean(value)
value = _UNSAFE.sub(' ', str(value)) if preserve_space else clean(value)
if not self.enabled:
return value
code = {'ok': '32', 'bad': '31', 'unknown': '33', 'title': '36', 'muted': '90', 'warn': '33'}[status]
@@ -126,6 +129,7 @@ class Console:
def banner(self, text):
"""A large ASCII-art title line, e.g. PROXYDOCTOR."""
text = clean(text)
if not self.enabled:
print(text, flush=True)
return
@@ -157,6 +161,8 @@ class Console:
"""A compact overview; wrap by terminal columns, including CJK text."""
if not 40 <= width <= 120:
raise ValueError('Panel width must be 40..120')
if sys.stdout.isatty():
width = min(width, max(12, shutil.get_terminal_size().columns))
if self._framed:
self.frame_line(title, 'title')
for label, value, status in rows:
@@ -201,6 +207,8 @@ class Console:
"""Top edge + centered title + optional subtitle, all inside the box."""
if not 40 <= box_w <= 118:
raise ValueError('Frame inner width must be 40..118')
if sys.stdout.isatty():
box_w = min(box_w, max(10, shutil.get_terminal_size().columns - 2))
cw = self._cw
self._box_w = box_w
self._framed = True
+25 -7
View File
@@ -3,11 +3,29 @@ import ipaddress
import json
import urllib.error
import urllib.request
from .console import clean
from .reports import utc_now
class GeoLocator:
def __init__(self, lang='zh', enabled=True):
self.lang,self.enabled,self.cache=lang,enabled,{}
self.metadata={}
self.lookups=0
def remember(self,address,data):
values=list(dict.fromkeys(clean(data[k])[:160] for k in ('country','region','city') if isinstance(data.get(k),str) and data[k]))
connection=data.get('connection') if isinstance(data.get('connection'),dict) else {}
asn=connection.get('asn')
if type(asn) is not int or not 1<=asn<=4294967295:asn=None
operator=connection.get('isp') or connection.get('org')
self.metadata[address]={'asn':asn,'operator':clean(operator)[:160] if isinstance(operator,str) else None,
'source':'https://ipwho.is/','observed_at':utc_now(),'geography':'database_estimate'}
return ' / '.join(values) if values else ('位置未知' if self.lang=='zh' else 'Unknown location')
def details(self,address):
location=self.lookup(address)
return {'location':location,**self.metadata.get(address,{'asn':None,'operator':None,'source':None,'geography':'unconfirmed'})}
def lookup(self,address):
addr=ipaddress.ip_address(address)
@@ -16,15 +34,17 @@ class GeoLocator:
unknown='位置未知' if self.lang=='zh' else 'Unknown location'
if not self.enabled: return unknown
if address in self.cache: return self.cache[address]
if self.lookups>=12:return unknown
self.lookups+=1
result=unknown
from .worker import enabled
if enabled():
from .net import request_json
language='zh-CN' if self.lang=='zh' else 'en'
data,state=request_json('https://ipwho.is/'+str(addr)+'?lang='+language)
if state=='ok' and data.get('success') is True:
values=list(dict.fromkeys(data[k] for k in ('country','region','city') if isinstance(data.get(k),str) and data[k]))
if values:result=' / '.join(values)
if state=='rate_limited':self.enabled=False
if state=='ok' and data.get('success') is True and data.get('ip',str(addr))==str(addr):
result=self.remember(address,data)
self.cache[address]=result
return result
class NoRedirect(urllib.request.HTTPRedirectHandler):
@@ -36,10 +56,8 @@ class GeoLocator:
payload=response.read(16385)
if len(payload)<=16384:
data=json.loads(payload)
if isinstance(data,dict) and data.get('success') is True:
location=[data.get(k) for k in ('country','region','city')]
values=list(dict.fromkeys(v for v in location if isinstance(v,str) and v))
if values: result=' / '.join(values)
if isinstance(data,dict) and data.get('success') is True and data.get('ip',str(addr))==str(addr):
result=self.remember(address,data)
except urllib.error.HTTPError as exc:
if exc.code==429: self.enabled=False
exc.close()
+38 -21
View File
@@ -7,10 +7,23 @@ 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():
@@ -33,10 +46,10 @@ def parse_traceroute_hop(text, ttl, target):
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):
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)
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<wait<=10:
raise ValueError('Invalid trace method or limits')
address=ipaddress.ip_address(target)
@@ -44,13 +57,13 @@ def trace_method(target, emit=print, *, method='udp', tcp_port=443, hops=30, wai
if not executable:
emit('TRACEROUTE_DEPENDENCY_MISSING · ICMP/TCP 需要系统 traceroute' if lang=='zh' else
'TRACEROUTE_DEPENDENCY_MISSING · ICMP/TCP require system traceroute',flush=True)
return 1
return finish_trace(summary,'dependency_missing','TRACEROUTE_DEPENDENCY_MISSING')
from .worker import local_command
deadline=deadline if deadline is not None else time.monotonic()+90
locator=GeoLocator(lang,enabled=geo)
for ttl in range(1,hops+1):
remaining=deadline-time.monotonic()
if remaining<=0:return 1
if remaining<=0:return finish_trace(summary,'partial' if records else 'not_reached','TIME_BUDGET_EXHAUSTED')
argv=[executable,'-6' if address.version==6 else '-4','-n','-N','1','-q','1',
'-f',str(ttl),'-m',str(ttl),'-w',str(wait),'-I' if method=='icmp' else '-T']
if method=='tcp':argv+=['-p',str(tcp_port)]
@@ -61,19 +74,19 @@ def trace_method(target, emit=print, *, method='udp', tcp_port=443, hops=30, wai
row=parse_traceroute_hop(text,ttl,address)
if row is None:
emit(error or 'TRACEROUTE_FAILED_OR_PERMISSION_DENIED',flush=True)
return 1
return finish_trace(summary,'permission_denied' if error=='PERMISSION_DENIED' else 'network_unavailable' if error=='NETWORK_UNREACHABLE' else 'unknown',error or 'TRACEROUTE_OUTPUT_INVALID')
row.update(method=method,statistics_scope='one_probe_this_ttl_not_forwarding_loss')
row['location']=clean(locator.lookup(row['ip'])) if row['ip'] else None
row.update(locator.details(row['ip']) if row['ip'] else {'location':None,'asn':None,'operator':None,'source':None})
row['response_loss_percent']=0.0 if row['responses'] else 100.0
if records is not None:records.append(row)
emit(f"{ttl:2d} {row['ip'] or '*':<39} {str(row['latency_ms']) if row['latency_ms'] is not None else '---':>8} ms {row['location'] or '---'}",flush=True)
emit(render_hop(row),flush=True)
if on_result:on_result()
if row['state']=='reached':return 0
if code or row['state']=='unreachable':return 1
return 1
if row['state']=='reached':return finish_trace(summary,'reached')
if code or row['state']=='unreachable':return finish_trace(summary,'not_reached',error or 'ICMP_UNREACHABLE')
return finish_trace(summary,'partial' if records and any(r.get('responses') for r in records) else 'not_reached','MAX_HOPS_REACHED')
def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=None, on_result=None, deadline=None):
def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=None, on_result=None, deadline=None,summary=None):
if not 1<=hops<=64 or not 0<wait<=10: raise ValueError('Invalid trace limits')
address=ipaddress.ip_address(target)
deadline=deadline if deadline is not None else time.monotonic()+90
@@ -81,7 +94,7 @@ def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=No
zh=lang=='zh';v6=address.version==6
if sys.platform != 'linux':
emit('当前平台无法进行原生追踪' if zh else 'Native trace unavailable on this platform',flush=True)
return 1
return finish_trace(summary,'unsupported','UNSUPPORTED_PLATFORM')
family=socket.AF_INET6 if v6 else socket.AF_INET
protocol=socket.IPPROTO_IPV6 if v6 else socket.IPPROTO_IP
recverr=25 if v6 else 11
@@ -92,7 +105,7 @@ def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=No
for ttl in range(1,hops+1):
if time.monotonic()>=deadline:
emit('Route observation time budget exhausted (90s)',flush=True)
return 1
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)
@@ -115,27 +128,31 @@ def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=No
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
location=clean(locator.lookup(hop))
emit(f'{ttl:2d} {hop:<39} {elapsed:8.1f} ms {location} [reply 1/1; timeout 0/1]',flush=True)
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
if records is not None: records.append({'hop':ttl,'ip':hop,'latency_ms':round(elapsed,3),'location':location,
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'})
'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 0
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 1
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):
except (OSError,AttributeError) as error:
emit('原生 UDP 追踪不可用或未收到允许的响应' if zh else 'Native UDP trace unavailable or blocked',flush=True)
return 1
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):
+2 -2
View File
@@ -33,8 +33,8 @@ def normalize_report(report):
add(f'network.{stage}.{i}','network',evidence.get('status'),stage,item.get('hostname',item.get('target')),evidence,evidence.get('error_code'))
route=report.get('route')
if route:
add('route.destination','route','success' if route.get('state')=='reached' else 'unknown',
route.get('method','udp')+'_trace',route.get('target'),{'state':route.get('state')},
add('route.destination','route','success' if route.get('state')=='reached' else 'unsupported' if route.get('state')=='unsupported' else 'skipped' if route.get('state')=='dependency_missing' else 'unknown',
route.get('method','udp')+'_trace',route.get('target'),{'state':route.get('state')},route.get('error_code'),
limitations=['Router silence is not end-to-end packet loss; location is estimated.'])
for i,item in enumerate(route.get('hops',[])):
add(f'route.hop.{i}','route','success' if item.get('state') in ('reply','reached') else 'unknown',
+9 -1
View File
@@ -21,7 +21,15 @@ class ScanSession:
if not self.store.checkpoint(self.report):self.failed=True
def __enter__(self):
self.checkpoint()
try:
self.checkpoint()
except BaseException:
# Context managers do not receive __exit__ when __enter__ raises.
# Preserve a cancellation snapshot and close the journal even if a
# signal arrives during the first durable checkpoint.
import sys
self.__exit__(*sys.exc_info())
raise
return self
def __exit__(self,kind,value,traceback):
+4 -1
View File
@@ -195,14 +195,17 @@ def run_suite(lang='zh',offline=False,sample_mib=4,route_target='1.1.1.1',besttr
deadline=time.monotonic()+90
replies=0;attempts=0
route['probe_rounds_requested']=route_probes
route['probe_outcomes']=[]
for number in range(route_probes):
if time.monotonic()>=deadline:break
out.line(tr(f'路由探测 {number+1}/{route_probes}',f'Route probe {number+1}/{route_probes}'))
options=dict(emit=route_output,hops=max_hops,lang=lang,geo=geo and number==0,
outcome={}
options=dict(emit=route_output,hops=max_hops,lang=lang,geo=geo and number==0,summary=outcome,
records=route['hops'],on_result=checkpoint,deadline=deadline)
route_code=(native_trace(route_target,**options) if route_method=='udp' else
protocol_trace(route_target,method=route_method,tcp_port=tcp_port,**options))
attempts+=1;replies+=route_code==0
route['probe_outcomes'].append(outcome)
route['statistics']=summarize_hops(route['hops'])
checkpoint()
route.update(state='reached' if replies else 'incomplete',probe_rounds_run=attempts,destination_responses=replies)
+11 -2
View File
@@ -39,7 +39,7 @@ def local_command(argv, timeout=4):
_active.add(process)
if _cancelled.is_set():process.kill()
try:
output, _ = process.communicate(timeout=timeout)
output, errors = process.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
process.kill();process.communicate()
return None, '', 'COMMAND_TIMEOUT'
@@ -50,7 +50,16 @@ def local_command(argv, timeout=4):
with _lock:_active.discard(process)
if len(output)>16384:
return None, '', 'COMMAND_OUTPUT_LIMIT'
return process.returncode, output, None
message=errors.lower()
error=None
if process.returncode:
if any(token in message for token in ('operation not permitted','permission denied','not enough privileges')):
error='PERMISSION_DENIED'
elif any(token in message for token in ('network is unreachable','network unreachable','no route to host')):
error='NETWORK_UNREACHABLE'
elif 'not supported' in message:
error='PROTOCOL_UNSUPPORTED'
return process.returncode, output, error
def invoke(action,options,timeout=20):
+74
View File
@@ -0,0 +1,74 @@
import os
import sys
import subprocess
from proxydoctor import routes, worker
from proxydoctor.geo import GeoLocator
from proxydoctor.console import Console, clean, width
def test_interrupt_during_first_fsync_preserves_cancelled_report(tmp_path, monkeypatch):
import json
import pytest
from proxydoctor.session import ScanSession
from proxydoctor.reports import recover_latest
from proxydoctor import worker
path = tmp_path/'startup.json'
session = ScanSession('route', path, 'en')
original = os.fsync
calls = []
def interrupt_once(fd):
original(fd)
if not calls:
calls.append(True)
raise KeyboardInterrupt
monkeypatch.setattr(os, 'fsync', interrupt_once)
try:
with pytest.raises(KeyboardInterrupt):
with session:
raise AssertionError('Operation must not begin')
assert json.loads(path.read_text())['result'] == 'cancelled'
assert recover_latest(str(path)+'.events.jsonl')['result'] == 'cancelled'
assert session.store.stream.closed
finally:worker.reset()
def test_real_command_error_is_classified_without_leaking_stderr():
code, text, error = worker.local_command([sys.executable, '-B', '-c',
'import sys;sys.stderr.write("socket: Operation not permitted: synthetic-secret");sys.exit(1)'])
assert code == 1 and not text and error == 'PERMISSION_DENIED'
def test_trace_preserves_permission_and_missing_dependency(monkeypatch):
monkeypatch.setattr(routes.sys, 'platform', 'linux')
monkeypatch.setattr(routes.shutil, 'which', lambda *a, **k: '/usr/bin/traceroute')
monkeypatch.setattr(worker, 'local_command', lambda *a, **k: (1, '', 'PERMISSION_DENIED'))
result = {}; rows = []
assert routes.trace_method('127.0.0.1', lambda *a, **k: None, method='tcp', records=rows, summary=result) == 1
assert result == {'state': 'permission_denied', 'error_code': 'PERMISSION_DENIED'} and not rows
monkeypatch.setattr(routes.shutil, 'which', lambda *a, **k: None)
routes.trace_method('127.0.0.1', lambda *a, **k: None, method='tcp', summary=result)
assert result['state'] == 'dependency_missing'
def test_geo_metadata_bounded_validated_and_terminal_safe(monkeypatch):
locator = GeoLocator('en')
location = locator.remember('8.8.8.8', {'country': 'US', 'connection': {'asn': 15169, 'isp': '\x1b[31mGoogle\u202e'}})
locator.cache['8.8.8.8'] = location
result = locator.details('8.8.8.8')
assert result['asn'] == 15169 and result['source'] == 'https://ipwho.is/'
assert result['geography'] == 'database_estimate' and result['observed_at']
assert '\x1b' not in result['operator'] and '\u202e' not in result['operator']
assert locator.details('127.0.0.1')['asn'] is None
locator.lookups = 12
assert locator.details('1.1.1.1')['asn'] is None
def test_narrow_terminal_frame_preserves_all_text(monkeypatch, capsys):
monkeypatch.setattr(sys.stdout, 'isatty', lambda: True)
monkeypatch.setattr('proxydoctor.console.shutil.get_terminal_size', lambda: os.terminal_size((32, 24)))
out = Console('zh', 'never')
out.frame_open('公测版'); out.frame_keyvalue('字段', '位置数据'*25); out.frame_close()
lines = capsys.readouterr().out.splitlines()
assert all(width(line) == 32 for line in lines)
assert '位置数据'*25 in ''.join(line[1:-1].strip() for line in lines if line.startswith('║'))
assert clean('\u202eevil\u2069\x1b') == 'evil'