import argparse import ipaddress import json from pathlib import Path import re import shutil import subprocess import sys from . import __version__, release_label from .checks import inspect from .i18n import translator from .quality import run_quality from .suite import run_suite from .bandwidth import SOURCES, CHINA_SOURCES LABELS = {"pass": "通过", "warn": "注意", "unknown": "未知", "skip": "跳过", "fail": "失败"} def redact(value, lang="zh"): """Mask IP addresses in exports; never claim arbitrary text is secret-free.""" if isinstance(value, dict): return {key: redact(item, lang) for key, item in value.items()} if isinstance(value, list): return [redact(item, lang) for item in value] if not isinstance(value, str): return value hidden = translator(lang)("[IP已隐藏]") def mask_v6(match): token = match.group(0) address = token.rstrip(".") try: ipaddress.IPv6Address(address.split("%", 1)[0]) return hidden + token[len(address):] except ValueError: return token # IPv6 first: replacing embedded IPv4 first would break mapped IPv6 parsing. value = re.sub(r"[0-9a-fA-F:.]+(?:%[a-zA-Z0-9_.-]+)?", mask_v6, value) return re.sub(r"(?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')) if args.besttrace or args.region_script: parser.error(st('安全流程暂停第三方程序调用,请使用原生检测','External programs are disabled in the safe workflow; use native checks')) if not 1<=args.port<=65535: parser.error(t('端口必须在 1~65535 之间')) if args.json_output and args.json_output.exists(): parser.error(st('输出文件已存在,请换一个文件名','Output already exists; choose another --json-output path')) if args.json_output and not args.json_output.parent.is_dir(): parser.error(st('输出目录不存在','Output directory does not exist')) try: ipaddress.ip_address(args.route_target) except ValueError: parser.error(st('--route-target 必须是 IPv4 或 IPv6 地址','Invalid --route-target: use an IPv4 or IPv6 address')) try: code=run_suite(lang=lang,offline=args.offline,sample_mib=args.sample_mib, route_target=args.route_target,besttrace=args.besttrace,region_script=args.region_script, 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='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) if args.command == 'quality': from .connectivity import run_console as run_connectivity from .session import ScanSession with ScanSession('network',None,lang) as scan: code=run_connectivity(lang=lang,color=args.color,report=scan.report['network'],on_result=scan.checkpoint) if code:scan.report['result']='incomplete' sys.exit(1 if scan.failed else code) if args.command == 'nodequality': parser.error(st('该第三方流程可能上传报告,当前安全版本已禁用;请使用 suite','This third-party workflow may upload reports and is disabled; use suite')) if not args.script.is_file(): parser.error(t("找不到本地脚本文件")) bash = shutil.which('bash') if bash is None: parser.error(t("未找到 bash,无法运行脚本")) print(t("即将运行第三方 NodeQuality;其可能下载组件、进行网络及性能测试,并向第三方上传结果。输出可能包含公网 IP。"), flush=True) try: result = subprocess.run([bash, str(args.script.resolve())], check=False) except OSError: parser.error(t("无法启动 NodeQuality 脚本")) sys.exit(result.returncode if result.returncode >= 0 else 128 - result.returncode) if not 1 <= args.port <= 65535: parser.error(t("端口必须在 1~65535 之间")) if args.public_ip: try: ipaddress.ip_address(args.public_ip) except ValueError: parser.error(t("--public-ip 必须是有效 IPv4 或 IPv6 地址")) raw_data = inspect(port=args.port, public_ip=args.public_ip, lang=lang, detect_public_ip=args.detect_public_ip) from .resources import collect as collect_resources, render as render_resources try: raw_data['resources'] = collect_resources() except Exception: raw_data['resources'] = {'status': 'unknown', 'error_code': 'RESOURCE_MODULE_ERROR'} from .security import collect as collect_security, render as render_security try: raw_data['security'] = collect_security() except Exception: raw_data['security'] = {'status':'unknown','error_code':'SECURITY_MODULE_ERROR'} from .schema import normalize_report normalize_report(raw_data) safe_data = redact(raw_data, lang) data = raw_data if args.show_ip else safe_data serialized = json.dumps(safe_data, ensure_ascii=False, indent=2) if args.output: try: with args.output.open("x", encoding="utf-8") as stream: stream.write(serialized + "\n") except OSError as exc: parser.error(t("无法保存报告:{error}", error=exc)) if args.as_json: print(json.dumps(data, ensure_ascii=False, indent=2)) else: from .console import Console, can_render_cjk console = Console(lang, getattr(args, 'color', 'auto')) theme = getattr(args, 'theme', 'default') use_theme = theme in ('iprisk', 'framed') separator = ": " if lang == "en" else ":" info_ids = {'system', 'architecture', 'kernel', 'cpu', 'memory_total', 'memory', 'disk_capacity', 'disk', 'interfaces', 'public_ip'} status_color = {'pass': 'ok', 'warn': 'bad', 'fail': 'bad', 'unknown': 'unknown', 'skip': 'unknown'} if theme == 'framed': console.frame_open(st('VPSMaster 检测报告','VPSMaster diagnostic report'), box_w=78, subtitle=release_label(lang) + ' · ' + __version__) # header key/values from datetime import datetime console.frame_keyvalue(st('扫描时间','Scan time'), datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')) console.frame_keyvalue(st('语言','Language'), st('中文','English')) console.frame_keyvalue(st('模式','Mode'), st('只读体检','Read-only')) for number, (title, is_info) in enumerate(((st('服务器信息','Server information'), True), (st('体检结果','Diagnostic results'), False)), start=1): console.frame_section(number, title, title) for item in data["checks"]: if item['id'] in ('memory_total','memory'): continue if (item['id'] in info_ids) != is_info: continue console.frame_keyvalue(item['title'], item['detail'], status_color.get(item['status'], 'unknown')) for line in item["evidence"].splitlines(): console.frame_line(st('依据','Evidence') + separator + line, 'muted') elif theme == 'iprisk': console.banner(' VPSMaster ' + ('v' + __version__)) console.line(release_label(lang), 'unknown') console.keyvalue(st('只读体检报告','Read-only diagnostic report'), st('已请求 ipify 查询出口 IP' if args.detect_public_ip else '默认不请求外部网络','ipify egress query' if args.detect_public_ip else 'no external requests by default'), 'title') console.hrule() for number, (title, is_info) in enumerate(((st('服务器信息','Server information'), True), (st('体检结果','Diagnostic results'), False)), start=1): console.theme_section(number, title, title, 'b_cyan') for item in data["checks"]: if item['id'] in ('memory_total','memory'): continue if (item['id'] in info_ids) != is_info: continue console.keyvalue(item['title'], item['detail'], status_color.get(item['status'], 'unknown')) for line in item["evidence"].splitlines(): print(' ' + console.paint(st('依据','Evidence'), 'muted') + separator + line, flush=True) else: print(release_label(lang) + f" · {__version__}\n" + t("只读体检 · 已请求 ipify 查询出口 IP" if args.detect_public_ip else "只读体检 · 默认不请求外部网络") + "\n") for heading, is_info in ((t('服务器信息'), True), (t('体检结果'), False)): print(f"{heading}:") for item in data["checks"]: if item['id'] in ('memory_total','memory'): continue if (item['id'] in info_ids) != is_info: continue print(f"[{t(LABELS[item['status']])}] {item['title']}{separator}{item['detail']}") for line in item["evidence"].splitlines(): print(" " + t("依据") + separator + line) print() counts = {t(label): sum(item["status"] == status for item in data["checks"]) for status, label in LABELS.items()} if data['resources']['status'] in ('partial','unsupported'): if console._framed: console.frame_section(3, '资源限制', 'Resource limits') render_resources(data['resources'], console) else: if console._framed: console.frame_line('RESOURCE_MODULE_ERROR', 'bad') else: print('RESOURCE_MODULE_ERROR') if data['security']['status'] in ('partial','unsupported'): if console._framed: console.frame_section(4, '安全与权限', 'Security & permissions') render_security(data['security'], console) else: if console._framed: console.frame_line('SECURITY_MODULE_ERROR', 'bad') else: print('SECURITY_MODULE_ERROR') if console._framed: summary = " / ".join(f"{key} {value}" for key, value in counts.items()) console.frame_section(5, st('汇总','Summary'), 'Summary') summary_status = 'bad' if any(row['status'] in ('warn','fail') for row in data['checks']) or data['security'].get('review_count') else 'unknown' if any(row['status']=='unknown' for row in data['checks']) else 'ok' console.frame_line(summary, summary_status) console.frame_line(st('终端完整 IP;文件脱敏。','Full terminal IP; exports redacted.') if args.show_ip else t('报告默认隐藏 IP。'), 'muted') console.frame_close() else: print("\n" + " / ".join(f"{key} {value}" for key, value in counts.items())) print(st('终端完整 IP;文件脱敏。','Full terminal IP; exports redacted.') if args.show_ip else t('报告默认隐藏 IP。')) # Stable automation contract: 1 = findings, 2 = invalid usage or export error. return_code = 1 if any(item["status"] in {"warn", "fail"} for item in data["checks"]) or data['security'].get('review_count',0) else 0 sys.exit(return_code) def main(): import os from .worker import reset,cancel_all previous=os.environ.get('PROXYDOCTOR_NETWORK_WORKERS') os.environ['PROXYDOCTOR_NETWORK_WORKERS']='1' reset() try: return _main() except KeyboardInterrupt: cancel_all() raise finally: if previous is None:os.environ.pop('PROXYDOCTOR_NETWORK_WORKERS',None) else:os.environ['PROXYDOCTOR_NETWORK_WORKERS']=previous