From 0fa26890f18e6162a8276ecc573cde9e576898b3 Mon Sep 17 00:00:00 2001 From: ProxyDoctor Checkpoint Date: Sun, 1 Nov 2026 13:02:29 -0800 Subject: [PATCH] Harden trace evidence, terminal rendering and interrupted session entry --- CURRENT_STATUS.md | 7 +++- KNOWN_LIMITATIONS.md | 19 +++++++++ MANIFEST.sha256.json | 30 ++++++++------ OPEN_SOURCE_REFERENCES.md | 22 +++++++++++ PROBLEM_BACKLOG.md | 1 + README.en.md | 6 +++ README.md | 5 ++- RELEASE_NOTES.md | 19 +++++++++ proxydoctor/carriers.py | 3 +- proxydoctor/cli.py | 5 ++- proxydoctor/console.py | 12 +++++- proxydoctor/geo.py | 32 +++++++++++---- proxydoctor/routes.py | 59 ++++++++++++++++++---------- proxydoctor/schema.py | 4 +- proxydoctor/session.py | 10 ++++- proxydoctor/suite.py | 5 ++- proxydoctor/worker.py | 13 +++++- tests/test_final_hardening.py | 74 +++++++++++++++++++++++++++++++++++ 18 files changed, 272 insertions(+), 54 deletions(-) create mode 100644 KNOWN_LIMITATIONS.md create mode 100644 OPEN_SOURCE_REFERENCES.md create mode 100644 RELEASE_NOTES.md create mode 100644 tests/test_final_hardening.py diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 4e9247a..c50905b 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -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;非最终版本,未推送、合并或公开发布。 diff --git a/KNOWN_LIMITATIONS.md b/KNOWN_LIMITATIONS.md new file mode 100644 index 0000000..e4456df --- /dev/null +++ b/KNOWN_LIMITATIONS.md @@ -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. diff --git a/MANIFEST.sha256.json b/MANIFEST.sha256.json index 808cc10..c1022b6 100644 --- a/MANIFEST.sha256.json +++ b/MANIFEST.sha256.json @@ -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", diff --git a/OPEN_SOURCE_REFERENCES.md b/OPEN_SOURCE_REFERENCES.md new file mode 100644 index 0000000..3cf327d --- /dev/null +++ b/OPEN_SOURCE_REFERENCES.md @@ -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. diff --git a/PROBLEM_BACKLOG.md b/PROBLEM_BACKLOG.md index 06d0c14..fb8bcb8 100644 --- a/PROBLEM_BACKLOG.md +++ b/PROBLEM_BACKLOG.md @@ -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)。以下包含本轮前历史统计,不能作为最新发布验收结论。 diff --git a/README.en.md b/README.en.md index 0f6b573..93cee7b 100644 --- a/README.en.md +++ b/README.en.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 diff --git a/README.md b/README.md index 2434733..34d9327 100644 --- a/README.md +++ b/README.md @@ -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 # 固定候选的小流量探测,不运行带宽测试 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..c93b7c3 --- /dev/null +++ b/RELEASE_NOTES.md @@ -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. diff --git a/proxydoctor/carriers.py b/proxydoctor/carriers.py index 162399e..0b5b76c 100644 --- a/proxydoctor/carriers.py +++ b/proxydoctor/carriers.py @@ -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() diff --git a/proxydoctor/cli.py b/proxydoctor/cli.py index 1468cc0..e5266d6 100644 --- a/proxydoctor/cli.py +++ b/proxydoctor/cli.py @@ -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') diff --git a/proxydoctor/console.py b/proxydoctor/console.py index 7af4f07..f297e9b 100644 --- a/proxydoctor/console.py +++ b/proxydoctor/console.py @@ -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 diff --git a/proxydoctor/geo.py b/proxydoctor/geo.py index 9867c1b..cac636c 100644 --- a/proxydoctor/geo.py +++ b/proxydoctor/geo.py @@ -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() diff --git a/proxydoctor/routes.py b/proxydoctor/routes.py index 287d6b5..39a7bdf 100644 --- a/proxydoctor/routes.py +++ b/proxydoctor/routes.py @@ -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 08} 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=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): diff --git a/proxydoctor/schema.py b/proxydoctor/schema.py index 22285fa..56369ba 100644 --- a/proxydoctor/schema.py +++ b/proxydoctor/schema.py @@ -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', diff --git a/proxydoctor/session.py b/proxydoctor/session.py index f66f328..4cf390b 100644 --- a/proxydoctor/session.py +++ b/proxydoctor/session.py @@ -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): diff --git a/proxydoctor/suite.py b/proxydoctor/suite.py index 4e1ab31..2b09297 100644 --- a/proxydoctor/suite.py +++ b/proxydoctor/suite.py @@ -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) diff --git a/proxydoctor/worker.py b/proxydoctor/worker.py index 1c08f8b..e678b12 100644 --- a/proxydoctor/worker.py +++ b/proxydoctor/worker.py @@ -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): diff --git a/tests/test_final_hardening.py b/tests/test_final_hardening.py new file mode 100644 index 0000000..f9dcae8 --- /dev/null +++ b/tests/test_final_hardening.py @@ -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'