Harden trace evidence, terminal rendering and interrupted session entry
This commit is contained in:
1 parent
9804b73cf0
commit
0fa26890f1
18 files changed
+272
-54
No files matched your search
+6
-1
@@ -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;非最终版本,未推送、合并或公开发布。
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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
|
||||
# 固定候选的小流量探测,不运行带宽测试
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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'
|
||||
Reference in new issue
Block a user