diff --git a/MANIFEST.sha256.json b/MANIFEST.sha256.json index e306bc0..85c5f06 100644 --- a/MANIFEST.sha256.json +++ b/MANIFEST.sha256.json @@ -8,8 +8,8 @@ "LICENSE": "07cace8b56becb2cd64969414c59082df9aa7453eeed8caab8378aaff38f6820", ".gitattributes": "1cbf58d18ccb72113d6d1bb3996400d557ccc83cda00a11cd63ef5ca38bbf199", ".gitignore": "7acff1fc699456d32b8d2a69205704d48193320644ed6d97c0f28612db75e87e", - "README.en.md": "8af18dd3ffc5b49209bea79c0f931174feda6b9476e62e8d56ab2c0d1905df1d", - "README.md": "b34606732b22930a29cd7cf3626c8ab7671738983cc164caae1070256043202d", + "README.en.md": "b650b259a9cc1f3c5bcae0bf05a4d9459a134439da16694e0149f5355b9f4448", + "README.md": "3d50e75e8f4c58243186b7b2552e68560c067e89f7727489a8e71bedd47cd994", "THIRD_PARTY_NOTICES.md": "5049c589e832f752d9561240facccd23456cb41123820e0a93bac1532b416afe", "proxydoctor/__init__.py": "78648a703705f6f05484eabdd61c9cc302943dd96c25c8448666d07a1eaa713b", "proxydoctor/__main__.py": "f21b83731acbaa16519e17fa795601ccd3543400cd5c59bb40098a488c225d84", @@ -17,27 +17,30 @@ "proxydoctor/bgp.py": "4c9fea00e616058700f73335fe1995764b85d610a89b22936b4e5dd9f563c0c6", "proxydoctor/carriers.py": "11506fef02973552ab74367f3c1f91c7a7ec484ac574c71eada6ab82fc5448aa", "proxydoctor/checks.py": "f010c09c834af2f4ac48128113bb5aebef9d1fb0c796891623d02232ccf09e31", - "proxydoctor/cli.py": "c88708f6bcfefdfe1f2b5eebe9651aec4c25d56b4658d987b5d502d61734a48b", + "proxydoctor/cli.py": "53b1669c1dc46e7ffaef87a44b8ddabbaac25484fd1980858c0f6aef6009ad13", "proxydoctor/connectivity.py": "2b6d4cfe69271ad582c3b0eb81bc69d38a9395c80cc12df2804bd01854f22385", - "proxydoctor/console.py": "f6d29e9483e28d3252bba182f8152dc39ba28767afe8e8323fa50fdda88d6d2b", + "proxydoctor/console.py": "d85d6bbb75698fa0851a5c00a90c292653e5052771665a6dad6b0404d9f49837", "proxydoctor/geo.py": "e4dde1452488743a0fc9a74e65bca65cad894ee357bc09c1d2b6dbdc2f0cfd04", "proxydoctor/i18n.py": "a7a72f7ce2d64d20564828438eba9becf68ee3ef33c7e39dc90efe767d636d4d", "proxydoctor/ipquality.py": "4dd1fc1421d11d03d8c803626393ca02d7be81527d4984da57c9ef2140c395bb", + "proxydoctor/limits.py": "8c9e44bab967e8c62eb791e58e10584150fc804529731ca67aae1f7b1868f5cd", "proxydoctor/mailquality.py": "ae1ad2e071bd7038b6ea7fbcbd63b7128eac9e5477f79f43c22172d6a5bb5071", "proxydoctor/media.py": "d7920b5d9d946ec40532a756df2329d37f6ede07f1d6c73f6ef63f43b1a95717", "proxydoctor/net.py": "4ea5a7e722dfa2adc97dacf49e5ba28a5980e91e165382fce140b75f206db4c4", + "proxydoctor/overview.py": "c307332872d3d25357058e72003c167b1401bcb55c3bf4d97e5cbd145075fe83", "proxydoctor/quality.py": "2e06f0d43cb95909fe4f14f08b39567b94cc1ee4ee03338fb8885fe1ec9fc878", "proxydoctor/reporting.py": "3037733647a6f9bc6753d144bd79e8b46e975f26f9a6b305080c55d87ee97b99", "proxydoctor/reports.py": "09bd0952c89397ef601a3421e563bdad1e74b17d07976dfcb4066a0353cea4ba", "proxydoctor/resources.py": "b2b7355ef8f09a16d28956e292dfb848c2ddc927c3773730c437cf53de4a09a4", "proxydoctor/routes.py": "c266cd3768b0c17add3a7c073d721556d7e31b3a07ef082967d949eb33bd65d3", - "proxydoctor/runner.py": "84462cb56bf335140a3aec08ace9c80dd745a9aeea99be618ac4483cb3430c8e", - "proxydoctor/schema.py": "6ae0e99c696fae778e88f96132142ac8eecb51a6a5df91ba38b816895aa1ca88", + "proxydoctor/runner.py": "93d3c0ea46889200199527e45c080cebe4a117f032772ba7f0ebd41f0295c5b9", + "proxydoctor/schema.py": "c7e0e5116b0bbe80dfcb6d895c1bb5e43ca7c9ae3662697000564c9735c43f77", "proxydoctor/security.py": "363327e43ab13dc8ebb9bb88f49fd14cea4aedca0952e9733159f99a195bb019", "proxydoctor/session.py": "3302f8aa1f031ca13c863ad0b464d94b11a41ee944e97cdb2f4d6ad2e7990737", - "proxydoctor/suite.py": "631a9b050c17a44abfd2b89630d362810743e51a83c2e503d03012a8f233d958", - "proxydoctor/worker.py": "c4745ba21aa68d4ce57c9bb8727841f8839d1c1c2d820e0e8e7ff071c88b0e06", + "proxydoctor/suite.py": "cd69adeb081080e2260a95e151ab3f60e8532cd5c9416945f398c8ae6b9a8539", + "proxydoctor/worker.py": "105eec8473d80b8a0118703328f797911b0aa269ce94f014e259b3cce2ff3d83", "tests/conftest.py": "9ba6f678d6297f2dc85f42121790246cd86b120dd52e537ee09d4c19f2cdf1b9", + "tests/test_all.py": "4950f98bb42576cb388580a1c30fb9e494cb7c423d6d083e67c2a20fffd93b22", "tests/test_alpha_quality.py": "a833be4ec7f0600383495b838ea1e95f50112003d47b2cc2cebbec50d0eba706", "tests/test_bandwidth_sources.py": "c9e41f7f3d25078feef335a549fb8a3229662d552b552f0579369ae61cfc659b", "tests/test_branding.py": "d9cf33286f9591564e722dd5c5be038eed85a4a3643f0348fb00f7d3db475ef6", @@ -84,11 +87,11 @@ "docs/guide/DATA_SERVICES.md": "d91be04176e6dda22c831cb9cd24f968ad82c1851c53bb6cbf356c51141b4a4c", "docs/guide/INSTALL.md": "320a91fdf7f97f27153dd383a05c5db1013aae1400e4f3a9e603b1579c21581a", "docs/guide/IPQUALITY_COVERAGE.md": "c83c14ae7a00700e484fa707546876fba512d5080adf9ec3827b941df4d3e4c1", - "docs/guide/USAGE.md": "7ede2dfa108b8b8a7563c7da6fd25525e20b1f4e25a4389ad1058573fc8a896d", + "docs/guide/USAGE.md": "bf94cc664b7aae5508ee3e5c5d3a23449bf00a5db950551251d5b7ca677742e6", "docs/README.md": "fc451f854a312ebf13fab73b1e2215105b2f97c5dd31cf832e7ea50d9a6b8fea", "docs/release/CHANGELOG.md": "a999eb3a33694afe4099a3ada9d69766c7584533dacfc0ae8a249163d9524ab9", "docs/release/KNOWN_LIMITATIONS.md": "37abd811206a4b2cefccacc445d48d08a70ebde364fc2e5a4050ec353cc3500a", - "docs/release/RELEASE_NOTES.md": "3118790651074d98c2a13779ae613b1d7538795d12834d7c85a9662d91a8717c", + "docs/release/RELEASE_NOTES.md": "484828fc8da99a38f66c28d878cab65f8b9ecf8ac41e0a721b32320d90d56ac6", "docs/security/ROADMAP.md": "bdec671b5690927df12fdc48ed54b55afdfa51fa573213944236630a7da0a497", "docs/security/SECURITY.md": "24dd0d7306bd3643f9d9773db51ea777d9049337afdd92d5ce3c6dfd38b2c81f", ".github/workflows/tests.yml": "a0d3002520e97206d94aa3ff9ee02be81f062c0dbb280c579af29e9d95846a58", diff --git a/README.en.md b/README.en.md index 7353acf..cea97a6 100644 --- a/README.en.md +++ b/README.en.md @@ -38,6 +38,24 @@ python3 -I -B install.py --run The installer creates an isolated user environment and prints the launcher path. The first scan is offline. Root is not required; the installer invokes neither pip, sudo nor a system package manager. Missing dependencies produce an error without changing the system. [Installation guide](docs/guide/INSTALL.md) +## One-command diagnostics + +Start with **`vpsmaster all`**: live module progress, results, a final summary and recoverable redacted reports, using the existing suite scheduler. + +Use the launcher printed by the installer, or define this alias in your current shell: + +```bash +alias vpsmaster="$HOME/.local/share/vpsmaster/1.0.0b1/bin/vpsmaster" +vpsmaster all +vpsmaster all --lang en +vpsmaster all --offline +vpsmaster all --full --allow-bandwidth +``` + +Default scope includes system/resources/interfaces, DNS/TCP/TLS, UDP and optional ICMP/TCP traces, IP/ASN/BGP, public pages and basic security. Missing dependencies, permissions and timeouts retain their reasons. Default limits: 300 seconds total, 60 per module; full mode allows 600 total. No CPU/disk benchmarks run. + +`--full` adds PTR/TCP25 and China-destination traces. Bandwidth requires separate `--allow-bandwidth` consent and has a 64 MiB test-payload cap. Mainland speed candidates require the separate experimental `--china-speed` switch. [Options and limits](docs/guide/USAGE.md) + ## Common commands Use the printed launcher path for custom installations; the default is: diff --git a/README.md b/README.md index 7128bbf..2f9e9d9 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,24 @@ python3 -I -B install.py --run 安装器在用户目录创建隔离环境,输出可执行入口路径;首次运行离线体检,不要求 root,也不调用 pip、sudo 或系统包管理器。缺少依赖时明确退出,不擅自修改系统。[安装、升级与卸载](docs/guide/INSTALL.md) +## 一键综合检测 + +新用户推荐入口:**`vpsmaster all`**。复用现有检测模块,实时显示正在执行的模块、完成数量、结果与最终汇总,并自动保存脱敏报告。 + +安装器会打印启动器路径;默认安装可在当前终端设置别名后使用: + +```bash +alias vpsmaster="$HOME/.local/share/vpsmaster/1.0.0b1/bin/vpsmaster" +vpsmaster all +vpsmaster all --lang zh +vpsmaster all --offline +vpsmaster all --full --allow-bandwidth +``` + +默认检测系统与资源、接口、DNS/TCP/TLS、UDP 与条件允许时的 ICMP/TCP、IP/ASN/BGP、网页可达性和基础安全。缺依赖、权限不足或超时记录原因后继续。默认总预算300秒、每模块60秒;完整模式总预算600秒,仍无 CPU/磁盘负载基准。 + +`--full` 增加 PTR/TCP25 与中国目标方向追踪;带宽需 `--allow-bandwidth`,测试载荷上限64 MiB。大陆测速候选另需实验性 `--china-speed`,不会默认启用。[全部选项与边界](docs/guide/USAGE.md) + ## 常用命令 默认安装使用下面的入口;自定义目录请使用安装器打印的路径。 diff --git a/docs/guide/USAGE.md b/docs/guide/USAGE.md index c272688..a064c03 100644 --- a/docs/guide/USAGE.md +++ b/docs/guide/USAGE.md @@ -49,3 +49,16 @@ - **这些 CPU/内存是独享吗?** 只反映来宾可见资源及可见配额,不能证明宿主硬件独享。 [安装指南](INSTALL.md) · [已知限制](../release/KNOWN_LIMITATIONS.md) + + +## all:面向初学者的一键入口 + +`all` 与 `suite` 共用参数、调度、worker、逐模块日志和报告;`suite` 保持原有执行范围。`all` 默认额外选择 BGP 与 ICMP/TCP(系统缺 traceroute 或权限时保留原因,不自动安装或 sudo)。`--skip-route` 同时跳过三种路由。`--offline` 不发起外部探测,所有联网模块明确跳过。 + +进度显示已完成模块数量,不虚构百分比。执行成功只表示模块运行结束,不能证明网络目标成功、安全、解锁或播放。`--show-unavailable` 可显示更多无数据条目;默认简洁展示仍保留每模块跳过/失败原因。 + +`--total-timeout 30..900` 设置总秒数,默认300、full600;`--module-timeout 5..120` 默认60。Linux CLI 主线程使用可恢复的作用域计时器取消阻塞操作;已有活动计时器、不支持计时器的平台或非主线程不能保证该边界时,明确标记不支持。到总预算后剩余模块记为跳过,总结果不完整。单模块超时记未知并尝试继续,其已落盘数据保留。计时预算不保证故障存储设备写入或外部服务端传输的严格实时时限。 + +默认串行、固定来源、有限响应和请求超时;UDP 最多64跳×3轮、位置查询上限12个公网地址。普通模式默认30跳;ICMP/TCP 不对无响应跳点作转发丢包结论。`--full` 启用 PTR/单目标TCP25与有界中国方向追踪,没有完整三网线路保证。 + +带宽须同时使用 `--full --allow-bandwidth`。按来源数×轮数×每方向样本×2进行保守测试载荷预算,最大64 MiB,超限在开始前拒绝;协议开销不计入载荷上限。大陆测速另需 `--china-speed`,可能需要降低 `--sample-mib` 以满足总预算。不会自动执行 CPU/磁盘基准。脱敏、独占保存、Ctrl+C恢复与旧命令一致。 diff --git a/docs/release/RELEASE_NOTES.md b/docs/release/RELEASE_NOTES.md index 1a295cb..05d5b56 100644 --- a/docs/release/RELEASE_NOTES.md +++ b/docs/release/RELEASE_NOTES.md @@ -33,3 +33,7 @@ MIT许可,保留历史版权和第三方来源说明。仅准备 Beta 候选 ## 仓库目录整理 README 中英文首页精简;指南、安全、开发、发行和历史材料分别归档。内部过程文档保留在本地备份,Git 历史不重写。Python 包、CLI 兼容入口与安装路径保持一致。目录和截图属于包内变更,因此另行构建、校验并从最终 ZIP 验收;原验收包保持不变。 + +## Beta.1 独立重建:all 综合入口 + +按所有者要求保持1.0.0b1版本标识。新增all复用suite机制、模块进度、时间预算、ICMP/TCP和BGP默认检测、最终汇总;full带宽仍需明确授权。与早期同名Beta.1 ZIP内容不同,必须用本次独立目录的SHA256核对;旧包保留。公开发行前需以最终包新验收为准。 diff --git a/proxydoctor/cli.py b/proxydoctor/cli.py index 1308751..625002e 100644 --- a/proxydoctor/cli.py +++ b/proxydoctor/cli.py @@ -121,7 +121,10 @@ def _main(): media.add_argument('--lang',choices=('zh','en'),default=lang) media.add_argument('--color',choices=('auto','always','never'),default='auto') media.add_argument('--json-output',type=Path,help=st('保存脱敏检测依据,不覆盖文件','Save redacted evidence; never overwrite')) - suite = sub.add_parser('suite',lang=lang,help=st('VPS / 网络质量 / 路由 / 流媒体','VPS / network / routes / streaming')) + suite = sub.add_parser('suite',aliases=['all'],lang=lang,help=st('一键综合检测:all(suite 兼容入口)','One-command diagnostics: all (suite compatible)')) + suite.add_argument('--full',action='store_true',help=st('all 扩展模式;带宽仍需独立授权','Extended all mode; bandwidth still requires consent')) + suite.add_argument('--total-timeout',type=int,choices=range(30,901),metavar='30..900',help=st('all 总秒数预算,默认300/full600','all total seconds; default 300/full600')) + suite.add_argument('--module-timeout',type=int,choices=range(5,121),default=60,metavar='5..120',help=st('all 每模块秒数预算,默认60','all per-module seconds, default 60')) suite.add_argument('--lang',choices=('zh','en'),default=lang) suite.add_argument('--allow-bandwidth',action='store_true',help=st('明确允许带宽测速(默认跳过)','Consent to bandwidth tests (skipped by default)')) suite.add_argument('--offline',action='store_true',help=st('跳过联网检测','Skip outbound requests')) @@ -259,7 +262,17 @@ def _main(): incomplete=any(item.get('state')!='reachable' for item in scan.report['media'].values()) if incomplete:scan.report['result']='incomplete' sys.exit(1 if incomplete or scan.failed else 0) - if args.command == 'suite': + if args.command in ('suite','all'): + is_all=args.command=='all' + if args.full and not is_all:parser.error('--full requires all') + if is_all and args.allow_bandwidth and not args.full:parser.error('all bandwidth requires --full --allow-bandwidth') + if is_all and args.full: + args.mail_probes=True + args.include_return_routes=True + if is_all and args.allow_bandwidth: + selected=args.bandwidth_source or (CHINA_SOURCES if args.china_speed else ('cloudflare','ovh')) + if args.sample_mib*args.bandwidth_rounds*len(set(selected))*2>64: + parser.error('all bandwidth payload budget is at most 64 MiB; reduce sources/sample size/rounds') if not 1<=args.tcp_port<=65535:parser.error(t('端口必须在 1~65535 之间')) if args.china_speed and not args.allow_bandwidth: parser.error(st('--china-speed 需要 --allow-bandwidth','--china-speed requires --allow-bandwidth')) @@ -280,7 +293,8 @@ def _main(): max_hops=args.max_hops,geo=not args.no_geo,color=args.color, bandwidth_rounds=args.bandwidth_rounds,skip_route=args.skip_route,json_output=args.json_output, bandwidth_sources=args.bandwidth_source or (CHINA_SOURCES if args.china_speed else None),port=args.port,show_ip=args.show_ip,ip_enrichment=not args.no_ip_enrichment,allow_bandwidth=args.allow_bandwidth,mail_probes=args.mail_probes,route_probes=args.route_probes,theme=args.theme, - route_method=args.route_method,tcp_port=args.tcp_port,include_return_routes=args.include_return_routes,include_bgp=args.include_bgp,show_unavailable=args.show_unavailable) + route_method='udp' if is_all else args.route_method,tcp_port=args.tcp_port,include_return_routes=args.include_return_routes,include_bgp=args.include_bgp or is_all,show_unavailable=args.show_unavailable, + comprehensive=is_all,total_timeout=args.total_timeout or (600 if args.full else 300),module_timeout=args.module_timeout) except OSError: parser.error(st('无法保存报告,请检查输出目录和文件权限','Unable to save report; check the output directory and file permissions')) sys.exit(code) diff --git a/proxydoctor/console.py b/proxydoctor/console.py index aab1e7b..efb4bb8 100644 --- a/proxydoctor/console.py +++ b/proxydoctor/console.py @@ -76,6 +76,7 @@ class Console: def __init__(self, lang='zh', color='auto', hide_unavailable=False): self.lang = lang self.hide_unavailable = hide_unavailable + self.wrap_output = False self.enabled = color == 'always' or (color == 'auto' and sys.stdout.isatty() and 'NO_COLOR' not in os.environ) self._framed = False # when True, row/keyvalue/section render inside a box @@ -102,13 +103,20 @@ class Console: return label = clean(label) w = width(label) + if self.wrap_output: + for piece in wrap_columns(label+' '+clean(value),max(12,shutil.get_terminal_size().columns)): + print(self.paint(piece,status),flush=True) + return print(label + ' ' * max(1, 19 - w) + self.paint(value, status), flush=True) def line(self, value='', status='ok'): if self._framed: self.frame_line(value, status) else: - print(self.paint(value, status, preserve_space=True), flush=True) + if self.wrap_output: + for piece in wrap_columns(value,max(12,shutil.get_terminal_size().columns)): + print(self.paint(piece,status),flush=True) + else:print(self.paint(value, status, preserve_space=True), flush=True) def section(self, index, zh, en): if self._framed: diff --git a/proxydoctor/limits.py b/proxydoctor/limits.py new file mode 100644 index 0000000..a0c6768 --- /dev/null +++ b/proxydoctor/limits.py @@ -0,0 +1,36 @@ +"""Scoped Linux main-thread deadlines; no background thread left doing work.""" +from contextlib import contextmanager +import signal +import threading +import time + + +class ModuleTimeout(BaseException): + pass + + +class DeadlineUnsupported(BaseException): + pass + + +@contextmanager +def bounded(seconds): + if seconds is None: + yield + return + if not hasattr(signal, 'setitimer') or threading.current_thread() is not threading.main_thread(): + raise DeadlineUnsupported() + previous_handler = signal.getsignal(signal.SIGALRM) + previous_timer = signal.getitimer(signal.ITIMER_REAL) + # Do not displace a caller's active alarm. + if previous_timer[0]: + raise DeadlineUnsupported() + def expired(*_): + raise ModuleTimeout() + signal.signal(signal.SIGALRM, expired) + signal.setitimer(signal.ITIMER_REAL, max(0.001, seconds)) + try: + yield + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, previous_handler) diff --git a/proxydoctor/overview.py b/proxydoctor/overview.py new file mode 100644 index 0000000..81bb112 --- /dev/null +++ b/proxydoctor/overview.py @@ -0,0 +1,27 @@ +"""User-facing summary of existing evidence; never invent a safety score.""" +def render_summary(report,out): + tr=out.text + out.line(tr('综合汇总(执行完成不代表目标全部可用)','Summary (execution is not target availability)'),'title') + checks={r['id']:r for r in (report.get('system') or {}).get('checks',[])} + system=checks.get('system',{}) + out.row(tr('系统','System'),system.get('evidence',tr('未确认','Unconfirmed'))) + allocation=(report.get('resources') or {}).get('allocation',{}) + out.row(tr('资源','Resources'),f"CPU {allocation.get('cpu_limit_cores','?')} vCPU; RAM {allocation.get('memory_limit_bytes','?')} B",'unknown') + network=report.get('network') or {} + dns=network.get('dns',[]);transport=network.get('transports',[]) + out.row('DNS / TCP / TLS',f"DNS {sum(r.get('status')=='success' for r in dns)}/{len(dns)}; TCP {sum(r.get('tcp',{}).get('status')=='success' for r in transport)}/{len(transport)}; TLS {sum(r.get('tls',{}).get('status')=='success' for r in transport)}/{len(transport)}",'unknown') + ip=report.get('ip_quality') or {} + values=ip.get('values') or {} + out.row('IP / ASN',str(values.get('asn') or 'unknown')+' · '+str(values.get('country') or 'unknown'),'unknown') + routes={'udp':report.get('route') or {},**report.get('additional_routes',{})} + for method,result in routes.items():out.row(method.upper(),result.get('state','not_run'),'unknown') + media=report.get('summary',{}).get('media',{}) + out.row(tr('网页可达','Pages reachable'),f"{media.get('reachable',0)}/{len(report.get('media',{}))} · "+tr('不代表解锁或播放','Not unlocking/playback'),'unknown') + security=report.get('security') or {} + high=sum(bool(set(row.get('reasons',[])) & {'WORLD_WRITABLE','SENSITIVE_WORLD_READABLE'}) for row in security.get('files',[])) + out.row(tr('权限风险提醒','Permission risk advisory'),f'high {high}; review {security.get("review_count","?")}', 'bad' if high else 'unknown') + out.row(tr('安全审阅','Security review'),str(security.get('review_count','unknown'))+' · '+tr('只读发现需人工评估,非漏洞严重度认证','Read-only findings need review, not certified vulnerability severity'),'unknown') + counts=report.get('summary',{}).get('module_counts',{}) + out.row(tr('模块统计','Module counts'),'; '.join(tr(zh,en)+' '+str(counts.get(key,0)) for key,zh,en in + [('success','完成','completed'),('failure','失败','failed'),('skipped','跳过','skipped'),('unknown','未知','unknown'),('unsupported','不支持','unsupported'),('cancelled','中断','cancelled')]),'title') + out.line(tr('关注:未知/超时不等于安全;检查报告中的错误与权限,再决定修复。','Action: unknown/timeouts are not safe; review errors and permissions before repair.'),'unknown') diff --git a/proxydoctor/runner.py b/proxydoctor/runner.py index 5d433db..7fdaae3 100644 --- a/proxydoctor/runner.py +++ b/proxydoctor/runner.py @@ -1,27 +1,48 @@ """Small synchronous module boundary; no global process or signal changes.""" from .reports import utc_now +import time +from .limits import bounded, ModuleTimeout, DeadlineUnsupported class ModuleRunner: - def __init__(self, report, checkpoint, notify): + def __init__(self, report, checkpoint, notify, observe=None, deadline=None, module_timeout=None): self.report = report self.checkpoint = checkpoint self.notify = notify + self.observe = observe + self.deadline = deadline + self.module_timeout = module_timeout report['modules'] = {} def run(self, name, operation=None, skip_reason=None): + if not skip_reason and self.deadline is not None and time.monotonic() >= self.deadline: + skip_reason = 'TOTAL_TIME_BUDGET' state = {'status': 'skipped' if skip_reason else 'running', 'started_at': utc_now(), 'error_code': None} self.report['modules'][name] = state + if self.observe:self.observe(name, state, False) if skip_reason: state['reason'] = skip_reason state['finished_at'] = utc_now() self.checkpoint() + if self.observe:self.observe(name, state, True) return self.checkpoint() try: - operation() + seconds = self.module_timeout + if self.deadline is not None: + seconds = min(seconds, max(0.001, self.deadline-time.monotonic())) + with bounded(seconds): + result = operation() state['status'] = 'success' + if isinstance(result,dict) and result.get('status') in ('unknown','skipped','unsupported'): + state.update(result) + except ModuleTimeout: + from .worker import cancel_all, reset + cancel_all();reset() + state.update(status='unknown', error_code='MODULE_TIMEOUT') + except DeadlineUnsupported: + state.update(status='unsupported', error_code='DEADLINE_UNSUPPORTED') except KeyboardInterrupt: state.update(status='cancelled', error_code='CANCELLED') raise @@ -33,3 +54,4 @@ class ModuleRunner: finally: state['finished_at'] = utc_now() self.checkpoint() + if self.observe:self.observe(name, state, True) diff --git a/proxydoctor/schema.py b/proxydoctor/schema.py index 56369ba..ea9636d 100644 --- a/proxydoctor/schema.py +++ b/proxydoctor/schema.py @@ -39,6 +39,9 @@ def normalize_report(report): for i,item in enumerate(route.get('hops',[])): add(f'route.hop.{i}','route','success' if item.get('state') in ('reply','reached') else 'unknown', 'single_'+route.get('method','udp')+'_probe',item.get('ip'),item,limitations=['One probe per TTL; response loss is not forwarding loss.']) + for method,item in report.get('additional_routes',{}).items(): + add('route.'+method,'route','success' if item.get('state')=='reached' else 'skipped' if item.get('state')=='dependency_missing' else 'unknown', + method+'_trace',item.get('target'),item,item.get('error_code'),['Router silence is not forwarding loss.']) for name in ('bgp','carrier_nodes','return_routes'): item=report.get(name) if item: diff --git a/proxydoctor/suite.py b/proxydoctor/suite.py index 91a2b85..e063195 100644 --- a/proxydoctor/suite.py +++ b/proxydoctor/suite.py @@ -25,7 +25,8 @@ def execute_provider(argv, timeout=180): def run_suite(lang='zh',offline=False,sample_mib=4,route_target='1.1.1.1',besttrace=None, region_script=None,max_hops=30,geo=True,color='auto',bandwidth_rounds=3,skip_route=False,json_output=None,bandwidth_sources=None,port=8388,show_ip=False,ip_enrichment=True,allow_bandwidth=False,mail_probes=False,route_probes=3,theme='default', - route_method='udp',tcp_port=443,include_return_routes=False,include_bgp=False,show_unavailable=False): + route_method='udp',tcp_port=443,include_return_routes=False,include_bgp=False,show_unavailable=False, + comprehensive=False,total_timeout=300,module_timeout=60): from .cli import redact from .console import Console import re @@ -37,6 +38,10 @@ def run_suite(lang='zh',offline=False,sample_mib=4,route_target='1.1.1.1',besttr if route_method not in ('udp','icmp','tcp') or not 1<=tcp_port<=65535 or theme not in ('default','iprisk','framed'): raise ValueError('Invalid route or theme options') out=Console(lang,color,hide_unavailable=not show_unavailable); tr=out.text + out.wrap_output=comprehensive + started=time.monotonic() + if comprehensive:include_bgp=True + if comprehensive and not (30<=total_timeout<=900 and 5<=module_timeout<=120):raise ValueError('Invalid all time budget') iprisk = theme == 'iprisk' framed = theme == 'framed' from datetime import datetime,timezone @@ -82,7 +87,30 @@ def run_suite(lang='zh',offline=False,sample_mib=4,route_target='1.1.1.1',besttr def module_error(name): out.row(name, tr('本模块异常,继续其他检测(MODULE_ERROR)','Module failed; continuing other checks (MODULE_ERROR)'),'bad') - runner = ModuleRunner(report, checkpoint, module_error) + labels={'system':('系统与接口','System and interfaces'),'resources':('资源状态','Resources'), + 'security':('安全体检','Security'),'bandwidth':('带宽','Bandwidth'),'network':('DNS / TCP / TLS','DNS / TCP / TLS'), + 'ip_quality':('IP 信息','IP information'),'route':('UDP 路由','UDP route'), + 'route_icmp':('ICMP 路由','ICMP route'),'route_tcp':('TCP 路由','TCP route'), + 'media':('网页可达性','Page reachability'),'bgp':('ASN / BGP','ASN / BGP'),'return_routes':('中国方向追踪','China destination routes')} + planned=8+(3 if comprehensive else int(include_bgp))+int(include_return_routes) + completed=0 + def progress(name,state,finished): + nonlocal completed + title=tr(*labels.get(name,(name,name))) + if finished: + completed+=1 + status=state['status'] + out.row(tr('进度','Progress'),f'{completed}/{planned} '+title+' · '+status+ + (' · '+str(state.get('error_code') or state.get('reason')) if state.get('error_code') or state.get('reason') else ''), + 'bad' if status=='failure' else 'ok' if status=='success' else 'unknown') + else:out.row(tr('正在执行','Running'),f'{completed}/{planned} · '+title,'title') + if comprehensive: + report['command']='all' + report['limits']={'total_seconds':total_timeout,'module_seconds':module_timeout,'concurrency':1,'bandwidth_payload_max_mib':64} + out.row(tr('检测预算','Time budget'),f'{total_timeout}s / module {module_timeout}s','title') + runner = ModuleRunner(report, checkpoint, module_error,observe=progress if comprehensive else None, + deadline=started+total_timeout if comprehensive else None, + module_timeout=module_timeout if comprehensive else None) def heading(number, zh, en, kind='b_cyan'): if iprisk: @@ -122,6 +150,15 @@ def run_suite(lang='zh',offline=False,sample_mib=4,route_target='1.1.1.1',besttr if key=='ssh' and item['status']=='warn': value=tr('允许 root 或密码认证','Root or password auth allowed') out.row(tr(zh,en),value,'bad' if item['status'] in ('warn','fail') else 'ok' if item['status']=='pass' else 'unknown') out.row('CPU benchmark',tr('未运行:常规检测不执行性能基准','Not run: routine checks do not benchmark CPU'),'unknown') + if comprehensive: + for key in ('interfaces','addresses'): + item=checks.get(key) + if item:out.row(tr('网络接口','Interfaces'),item.get('evidence',''),'unknown') + from .worker import local_command + tool=shutil.which('systemd-detect-virt',path='/usr/bin:/bin') + code,value,error=local_command([tool],timeout=3) if tool else (None,'','COMMAND_UNAVAILABLE') + report['virtualization']={'value':value.strip() if code==0 else None,'method':'systemd-detect-virt','error_code':error if code else None} + out.row(tr('虚拟化','Virtualization'),value.strip() if code==0 else tr('未确认','Unconfirmed'),'ok' if code==0 else 'unknown') def collect_bandwidth(): nonlocal data, incomplete @@ -243,11 +280,24 @@ def run_suite(lang='zh',offline=False,sample_mib=4,route_target='1.1.1.1',besttr if not address: report['bgp']={'status':'unknown','error_code':'EGRESS_IP_UNAVAILABLE'} out.row('BGP','EGRESS_IP_UNAVAILABLE','unknown');incomplete=True - return + return {'status':'unknown','error_code':'EGRESS_IP_UNAVAILABLE'} report['bgp']={} collect(address,report['bgp'],checkpoint);render(report['bgp'],out) incomplete=report['bgp']['status']!='success' or incomplete + def additional_route(method): + nonlocal incomplete + route={'target':route_target,'method':method,'hops':[]} + report.setdefault('additional_routes',{})[method]=route + protocol_trace(route_target,method=method,tcp_port=tcp_port,hops=max_hops,lang=lang, + geo=False,emit=lambda text,**kw:out.line(text,'unknown'),records=route['hops'], + summary=route,on_result=checkpoint,deadline=time.monotonic()+module_timeout) + state=route.get('state') + if state!='reached': + incomplete=True + status='skipped' if state=='dependency_missing' else 'unsupported' if state=='unsupported' else 'unknown' + return {'status':status,'error_code':route.get('error_code') or 'ROUTE_INCOMPLETE'} + cancelled = False try: runner.run('system', collect_system) @@ -264,6 +314,9 @@ def run_suite(lang='zh',offline=False,sample_mib=4,route_target='1.1.1.1',besttr heading(3,'路由追踪','Route trace') out.row(tr('路由','Route'),tr('已跳过','Skipped'),'unknown') runner.run('route', collect_route, 'offline' if offline else 'user_skipped' if skip_route else None) + if comprehensive: + for method in ('icmp','tcp'): + runner.run('route_'+method,lambda m=method:additional_route(m),'offline' if offline else 'user_skipped' if skip_route else None) if offline and show_unavailable: heading(4,'流媒体可达性','Media reachability') out.row(tr('流媒体','Media'),tr('离线跳过','Skipped offline'),'unknown') @@ -276,7 +329,7 @@ def run_suite(lang='zh',offline=False,sample_mib=4,route_target='1.1.1.1',besttr cancelled = True finally: report['finished_at'] = utc_now() - failed = any(item['status'] == 'failure' for item in report['modules'].values()) + failed = any(item['status'] in ('failure','unknown','unsupported') or item.get('reason')=='TOTAL_TIME_BUDGET' for item in report['modules'].values()) report['result'] = 'cancelled' if cancelled else 'incomplete' if incomplete or failed else 'local_execution_complete' report['summary'] = { 'playback': 'not_tested', @@ -287,11 +340,18 @@ def run_suite(lang='zh',offline=False,sample_mib=4,route_target='1.1.1.1',besttr for item in report['media'].values()) for state in ('reachable','access_denied','rate_limited','http_error','unknown')}, } + if comprehensive: + from collections import Counter + report['summary']['module_counts']=dict(Counter(item['status'] for item in report['modules'].values())) + report['summary']['module_count_note']='Execution success does not imply target availability or safety.' from .schema import normalize_report normalize_report(report) saved = store.finish(report) store.close() out.row(tr('体检结果','Results'), report['result'], 'unknown' if cancelled or incomplete or failed else 'ok') + if comprehensive: + from .overview import render_summary + render_summary(report,out) if saved and not store.failed: out.row(tr('脱敏报告','Redacted report'), str(store.path)) else: diff --git a/proxydoctor/worker.py b/proxydoctor/worker.py index e678b12..78ca11b 100644 --- a/proxydoctor/worker.py +++ b/proxydoctor/worker.py @@ -83,6 +83,9 @@ def invoke(action,options,timeout=20): except KeyboardInterrupt: cancel_all();process.communicate() raise + except BaseException: + process.kill();process.communicate() + raise finally: with _lock:_active.discard(process) if process.returncode or len(output)>2*1024**2: diff --git a/tests/test_all.py b/tests/test_all.py new file mode 100644 index 0000000..dd08722 --- /dev/null +++ b/tests/test_all.py @@ -0,0 +1,99 @@ +import contextlib +import json +import signal +import sys +import time +from pathlib import Path +import pytest +from proxydoctor import cli,suite,worker +from proxydoctor.runner import ModuleRunner + + +def test_cli_all_reuses_suite(monkeypatch): + captured={} + monkeypatch.setattr(sys,'argv',['vpsmaster','all','--offline','--lang','en']) + monkeypatch.setattr(cli,'run_suite',lambda **kw:captured.update(kw) or 0) + with pytest.raises(SystemExit) as ex:cli._main() + assert ex.value.code==0 + assert captured['comprehensive'] and captured['include_bgp'] and captured['offline'] + assert not captured['allow_bandwidth'] and not captured['include_return_routes'] + + +def test_full_requires_bandwidth_consent_and_no_implicit_china(monkeypatch): + captured={} + monkeypatch.setattr(cli,'run_suite',lambda **kw:captured.update(kw) or 0) + monkeypatch.setattr(sys,'argv',['vpsmaster','all','--full']) + with pytest.raises(SystemExit) as ex:cli._main() + assert ex.value.code==0 and not captured['allow_bandwidth'] + assert captured['include_return_routes'] and captured['mail_probes'] + assert captured['bandwidth_sources'] is None + monkeypatch.setattr(sys,'argv',['vpsmaster','all','--allow-bandwidth']) + with pytest.raises(SystemExit) as ex:cli._main() + assert ex.value.code==2 + + +@pytest.mark.parametrize('lang',['zh','en']) +def test_all_offline_keeps_skips_without_network(tmp_path,monkeypatch,capsys,lang): + import proxydoctor.runner as runner + monkeypatch.setattr(runner,'bounded',lambda n:contextlib.nullcontext()) + monkeypatch.chdir(tmp_path) + def blocked(*a,**kw):raise AssertionError('Offline initiated network') + for name in ('collect_ipquality','run_media','protocol_trace','native_trace','measure_bandwidth'): + monkeypatch.setattr(suite,name,blocked) + monkeypatch.setattr('proxydoctor.connectivity.run_console',blocked) + monkeypatch.setattr('proxydoctor.bgp.collect',blocked) + path=tmp_path/'offline.json' + assert suite.run_suite(comprehensive=True,offline=True,lang=lang,color='never',json_output=path) in (0,1) + report=json.loads(path.read_text(encoding='utf-8')) + assert report['command']=='all' and len(report['modules'])==11 + for name in ('network','ip_quality','route','route_icmp','route_tcp','bgp','media'): + assert report['modules'][name]['status']=='skipped' + assert sum(report['summary']['module_counts'].values())==11 + text=capsys.readouterr().out + assert '11/11' in text and '\x1b[' not in text + + +def test_module_failure_and_budget_continue_without_fake_success(): + report={};events=[] + runner=ModuleRunner(report,lambda:None,lambda _:None,observe=lambda n,s,end:events.append((n,end))) + def bad():raise ValueError('secret must not be displayed') + runner.run('bad',bad);runner.run('good',lambda:None) + runner.deadline=time.monotonic()-1 + runner.run('budget',lambda:pytest.fail('Expired budget executed operation')) + assert report['modules']['bad']['status']=='failure' + assert report['modules']['good']['status']=='success' + assert report['modules']['budget']['reason']=='TOTAL_TIME_BUDGET' + assert len(events)==6 + + +@pytest.mark.skipif(not hasattr(signal,'setitimer'),reason='Linux scoped deadline test') +def test_module_timeout_reaps_child_and_next_module_runs(): + report={};worker.reset();started=time.monotonic() + runner=ModuleRunner(report,lambda:None,lambda _:None,module_timeout=.15) + runner.run('timeout',lambda:worker.local_command([sys.executable,'-c','import time;time.sleep(30)'],timeout=35)) + runner.run('next',lambda:None) + assert time.monotonic()-started<3 + assert report['modules']['timeout']['error_code']=='MODULE_TIMEOUT' + assert report['modules']['next']['status']=='success' + assert not worker._active + assert signal.getitimer(signal.ITIMER_REAL)[0]==0 + + +def test_missing_trace_dependency_has_structured_reason(tmp_path,monkeypatch,capsys): + import proxydoctor.runner as runner + from test_suite import unavailable_ip + monkeypatch.setattr(runner,'bounded',lambda n:contextlib.nullcontext()) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(suite,'collect_ipquality',unavailable_ip) + monkeypatch.setattr('proxydoctor.connectivity.run_console',lambda *a,**kw:1) + monkeypatch.setattr(suite,'native_trace',lambda *a,**kw:1) + monkeypatch.setattr(suite,'protocol_trace',lambda *a,**kw:kw['summary'].update(state='dependency_missing',error_code='TRACEROUTE_DEPENDENCY_MISSING') or 1) + monkeypatch.setattr(suite,'run_media',lambda *a,**kw:kw['results'].update(Netflix={'state':'unknown'})) + path=tmp_path/'online.json' + assert suite.run_suite(comprehensive=True,json_output=path,color='never')==1 + report=json.loads(path.read_text()) + assert report['modules']['route_icmp']['status']=='skipped' + assert report['modules']['route_tcp']['error_code']=='TRACEROUTE_DEPENDENCY_MISSING' + assert report['modules']['bgp']['status']=='unknown' + assert report['summary']['failed_modules']==0 + assert '11/11' in capsys.readouterr().out