"""Read-only checks. No shell, privilege escalation or system mutations.""" from dataclasses import asdict, dataclass from datetime import datetime, timezone import ipaddress import json import os import platform import re from pathlib import Path import shutil import subprocess import urllib.error import urllib.request from .i18n import translator from . import __version__ @dataclass class Result: id: str status: str title: str detail: str evidence: str = "" advice: str = "" @dataclass class Command: code: int | None output: str = "" reason: str = "" # Only these fixed command arguments may reach subprocess. ALLOWED = { ("ip", "-j", "address", "show"), ("ip", "-j", "-4", "route", "show", "default"), ("ip", "-j", "-6", "route", "show", "default"), ("ss", "-H", "-lntu"), ("sshd", "-T"), ("ufw", "status"), ("nft", "list", "ruleset"), ("iptables", "-S"), ("systemctl", "is-active", "sing-box.service"), ("systemctl", "is-enabled", "sing-box.service"), } SYSTEM_PATH = "/usr/sbin:/usr/bin:/sbin:/bin" OUTPUT_LIMIT = 131072 class Host: def __init__(self, lang="zh"): self.lang = lang def system(self): return platform.system(), platform.machine() def read(self, path): try: return Path(path).read_text(encoding="utf-8") except (OSError, UnicodeError): return None def disk_free(self): return shutil.disk_usage("/").free def disk_total(self): return shutil.disk_usage("/").total def cpu_count(self): return os.cpu_count() def kernel(self): return platform.release() def ssh_connection(self): return os.environ.get("SSH_CONNECTION", "") def detect_public_ip(self): from .worker import enabled if enabled(): from .net import request_json data,state=request_json('https://api.ipify.org?format=json') try: address=ipaddress.ip_address(data.get('ip')) if state=='ok' else None return str(address) if address and address.is_global else None except (ValueError,TypeError,AttributeError):return None # Explicit opt-in; no environment proxies or redirects. class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None try: opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) with opener.open('https://api64.ipify.org', timeout=5) as response: payload = response.read(129) if len(payload) > 128: return None address = ipaddress.ip_address(payload.decode('ascii').strip()) return str(address) if address.is_global else None except (urllib.error.URLError, OSError, UnicodeError, ValueError): return None def run(self, *args): t = translator(self.lang) if args not in ALLOWED: raise ValueError("Command is not allowlisted") # Ignore the caller's PATH, especially when a user chooses to run as root. executable = shutil.which(args[0], path=SYSTEM_PATH) if executable is None and args[0] in {"sshd", "ufw", "nft", "iptables"}: for directory in ("/usr/sbin", "/sbin"): candidate = Path(directory) / args[0] if candidate.is_file(): executable = str(candidate) break if executable is None: return Command(None, reason=t("命令不可用")) try: proc = subprocess.run( [executable, *args[1:]], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=8, check=False, env={"PATH": SYSTEM_PATH, "LC_ALL": "C"}, ) # Never return stderr: it can contain unrelated host details. if len(proc.stdout) > OUTPUT_LIMIT: return Command(None, reason=t("输出超过读取上限,不能作为完整检查结果")) return Command(proc.returncode, proc.stdout, t("读取命令失败,可能是权限、配置或服务状态问题") if proc.returncode else "") except subprocess.TimeoutExpired: return Command(None, reason=t("读取超时(8 秒)")) except OSError: return Command(None, reason=t("无法执行读取命令")) def parse_os(text): values = {} for line in (text or "").splitlines(): if "=" in line and not line.startswith("#"): key, value = line.split("=", 1) values[key] = value.strip().strip('"').strip("'") return values def port_rows(text): """Parse ss -H -lntu without relying on process ownership visibility.""" rows = [] malformed = False for line in text.splitlines(): fields = line.split() if len(fields) < 6 or fields[0] not in {"tcp", "udp"}: malformed = True continue endpoint = fields[4] try: port = int(endpoint.rsplit(":", 1)[1]) if not 1 <= port <= 65535: raise ValueError("Invalid port") except (ValueError, IndexError): malformed = True continue rows.append((fields[0], endpoint, port)) return rows, malformed def ubuntu_version(value): """Parse VERSION_ID numerically; never infer Ubuntu from ID_LIKE.""" if not re.fullmatch(r"[0-9]{2}\.(04|10)", value or ""): return None return tuple(int(part) for part in value.split(".")) def route_check(host, t): evidence, valid, failed = [], 0, False for family in ("-4", "-6"): result = host.run("ip", "-j", family, "route", "show", "default") try: if result.code != 0: raise ValueError routes = json.loads(result.output) if not isinstance(routes, list): raise ValueError for route in routes: if not isinstance(route, dict) or route.get("dst") not in ("default", "0.0.0.0/0", "::/0"): raise ValueError if route.get("type", "unicast") != "unicast": continue dev = route.get("dev") gateway = route.get("gateway") if not isinstance(dev, str) or not dev or (gateway is not None and not isinstance(gateway, str)): raise ValueError if gateway is not None: address = ipaddress.ip_address(gateway) if address.version != (4 if family == "-4" else 6): raise ValueError evidence.append(f"IPv{family[-1]}: {dev}" + (f" via {gateway}" if gateway else "")) valid += 1 except (ValueError, TypeError, KeyError): failed = True evidence.append(f"IPv{family[-1]}: " + t("路由读取失败或格式无法解析")) return Result("default_route", "pass" if valid and not failed else "unknown", t("默认路由"), t("已读取默认路由") if valid and not failed else t("默认路由信息不完整或未找到,无法确认网络出口"), "\n".join(evidence), t("仅检查当前网络命名空间的默认路由,不覆盖策略路由。")) def dns_check(host, t): content = host.read("/etc/resolv.conf") if content is None: return Result("dns_config", "unknown", t("DNS 配置"), t("无法读取 DNS 配置")) addresses, invalid = [], False for line in content.splitlines(): line = line.split("#", 1)[0].split(";", 1)[0].strip() fields = line.split() if fields and fields[0] == "nameserver": if len(fields) != 2: invalid = True continue try: address = ipaddress.ip_address(fields[1]) if address.is_unspecified or address.is_multicast: raise ValueError addresses.append(address) except ValueError: invalid = True evidence = "\n".join(str(addr) for addr in addresses) stub = any(addr.is_loopback for addr in addresses) status = "unknown" if invalid or not addresses or stub else "pass" detail = (t("DNS 配置不完整或没有有效的服务器地址") if invalid or not addresses else t("DNS 指向本机解析器") if stub else t("已读取 DNS 服务器配置")) return Result("dns_config", status, t("DNS 配置"), detail, evidence, t("仅检查 resolv.conf,不主动发送 DNS 请求;实际解析路径可能不同。")) def ssh_port_check(host, ssh, port, t): configured, malformed = [], False if ssh.code == 0: for line in ssh.output.splitlines(): fields = line.split() if fields and fields[0] == "port": try: if len(fields) != 2 or not 1 <= int(fields[1]) <= 65535: raise ValueError configured.append(int(fields[1])) except ValueError: malformed = True try: session = host.ssh_connection() except AttributeError: session = "" session_port = None if session: try: fields = session.split() if len(fields) != 4: raise ValueError ipaddress.ip_address(fields[0]); ipaddress.ip_address(fields[2]) if not 1 <= int(fields[1]) <= 65535 or not 1 <= int(fields[3]) <= 65535: raise ValueError session_port = int(fields[3]) except ValueError: session_port = None evidence = [] if configured: evidence.append(t("sshd 默认配置端口:{ports}", ports=", ".join(map(str, sorted(set(configured)))))) if session_port is not None: evidence.append(t("当前 SSH 会话本地端口:{port}", port=session_port)) matched = port in configured or port == session_port detail = t("目标端口与 SSH 已知端口冲突;不要用于代理") if matched else t("未能排除与运行中 SSH 端口冲突") if malformed: evidence.append(t("SSH 端口输出不完整或无效")) return Result("ssh_port_safety", "warn" if matched else "unknown", t("SSH 端口保护"), detail, "\n".join(evidence), "") def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=False): t = translator(lang) host = host or Host(lang) results = [] add = results.append system, arch = host.system() os_info = parse_os(host.read("/etc/os-release")) if system == "Linux" else {} version = ubuntu_version(os_info.get("VERSION_ID")) is_ubuntu = os_info.get("ID") == "ubuntu" supported = is_ubuntu and version is not None and version >= (22, 4) os_unknown = system == "Linux" and not os_info.get("ID") version_unknown = is_ubuntu and version is None add(Result("system", "pass" if supported else "unknown" if version_unknown or os_unknown else "warn", t("操作系统"), t("版本符合 Ubuntu 22.04 最低要求;实机兼容性按已测试环境确认") if supported else t("无法读取 Linux 发行版标识,不能确认系统要求") if os_unknown else t("无法解析 Ubuntu 版本,不能确认是否符合最低要求") if version_unknown else t("需要 Ubuntu 22.04 或更新版本"), f"{system}; {os_info.get('PRETTY_NAME', t('版本未知'))}; {arch}", t("支持范围:Ubuntu 22.04 及更新版本。") if supported else t("其他系统的结果仅供参考。"))) add(Result("architecture", "pass" if arch.lower() in {"x86_64", "amd64", "aarch64", "arm64"} else "unknown", t("系统架构"), t("检测架构;本阶段不验证安装包兼容性"), arch)) try: count = host.cpu_count() except (AttributeError, OSError): count = None cpuinfo = host.read('/proc/cpuinfo') if system == 'Linux' else None model = next((line.split(':', 1)[1].strip() for line in (cpuinfo or '').splitlines() if line.split(':', 1)[0].strip() in ('model name', 'Hardware') and ':' in line), None) add(Result('cpu', 'pass' if count else 'unknown', t('CPU 信息'), t('系统可见逻辑 CPU:{count}', count=count) if count else t('逻辑 CPU 数量未知'), t('处理器型号:{model}', model=model) if model else t('处理器型号未知'), t('逻辑 CPU 数不一定等于物理核心数;配额限制可能不同。'))) try: kernel = host.kernel() except (AttributeError, OSError): kernel = None add(Result('kernel', 'pass' if kernel else 'unknown', t('内核版本'), kernel or t('无法读取'))) try: free = host.disk_free() / 1024**3 add(Result("disk", "pass" if free >= 1 else "warn", t("根分区可用空间"), t("可用 {free:.1f} GiB(体检提示阈值 1 GiB)", free=free))) except OSError: add(Result("disk", "unknown", t("根分区可用空间"), t("无法读取"))) try: total, free = host.disk_total() / 1024**3, host.disk_free() / 1024**3 add(Result('disk_capacity', 'pass', t('根分区容量'), t('总容量 {total:.1f} GiB,可用 {free:.1f} GiB', total=total, free=free))) except (AttributeError, OSError): add(Result('disk_capacity', 'unknown', t('根分区容量'), t('无法读取'))) if public_ip and detect_public_ip: raise ValueError("public_ip and detect_public_ip are mutually exclusive") if detect_public_ip: address = host.detect_public_ip() add(Result("public_ip", "unknown", t("公网 IP"), t("已通过 ipify 查询出口地址;入站可达性未确认") if address else t("出口 IP 查询失败,无法确定公网地址"), address or "", t("出口地址可能是 NAT 或代理地址。"))) elif public_ip: addr = ipaddress.ip_address(public_ip) add(Result("public_ip", "unknown" if addr.is_global else "warn", t("公网 IP"), t("用户提供的地址具备公网地址属性") if addr.is_global else t("提供的地址不是全局可路由公网地址"), str(addr))) else: add(Result("public_ip", "unknown", t("公网 IP"), t("默认不向第三方查询出口 IP"))) add(Result("cloud_firewall", "unknown", t("云防火墙与外部可达性"), t("本机读取不能确认云安全组、NAT 或外部网络路径"))) if system != "Linux": for key, title in (("memory", t("内存")), ("interfaces", t("网络地址")), ("default_route", t("默认路由")), ("dns_config", t("DNS 配置")), ("ports", t("端口占用")), ("ssh", t("SSH 配置")), ("ssh_port_safety", t("SSH 端口保护")), ("firewall", t("本机防火墙")), ("service", t("sing-box 服务")), ("service_startup", t("sing-box 开机启动"))): add(Result(key, "skip", title, t("此项需要 Linux;当前主机仅验证程序运行"))) return report(results, port, lang) memory = host.read("/proc/meminfo") total_line = next((line for line in (memory or '').splitlines() if line.startswith('MemTotal:')), '') try: total_mib = int(total_line.split()[1]) / 1024 add(Result('memory_total', 'pass', t('内存总量'), t('系统可见 {total:.0f} MiB', total=total_mib))) except (ValueError, IndexError): add(Result('memory_total', 'unknown', t('内存总量'), t('无法读取'))) available = next((line for line in (memory or "").splitlines() if line.startswith("MemAvailable:")), "") try: mib = int(available.split()[1]) / 1024 add(Result("memory", "pass" if mib >= 256 else "warn", t("可用内存"), t("约 {mib:.0f} MiB(体检提示阈值 256 MiB)", mib=mib))) except (ValueError, IndexError): add(Result("memory", "unknown", t("可用内存"), t("无法读取 MemAvailable"))) network = host.run("ip", "-j", "address", "show") try: interfaces = json.loads(network.output) if network.code == 0 else None addresses = [f"{item['ifname']}: {addr['local']}" for item in interfaces for addr in item.get("addr_info", []) if addr.get("scope") != "host"] add(Result("interfaces", "pass" if addresses else "unknown", t("网络地址"), t("仅表示本机接口地址"), "\n".join(addresses))) except (TypeError, ValueError, KeyError, AttributeError): add(Result("interfaces", "unknown", t("网络地址"), t(network.reason) or t("无法解析接口信息"))) add(route_check(host, t)) add(dns_check(host, t)) ports = host.run("ss", "-H", "-lntu") rows, malformed = port_rows(ports.output) conflicts = [f"{proto} {endpoint}" for proto, endpoint, number in rows if number == port] add(Result("ports", "warn" if conflicts else "unknown" if ports.code != 0 or malformed else "pass", t("目标端口占用"), t("目标 TCP/UDP 端口 {port} 已有监听", port=port) if conflicts else t("无法完整确认端口占用") if ports.code != 0 or malformed else t("当前未发现 TCP/UDP 端口 {port} 的监听", port=port), "\n".join(conflicts), t("选择未占用端口;不会停止已有服务。") if conflicts else "")) ssh = host.run("sshd", "-T") add(ssh_port_check(host, ssh, port, t)) # Only retain selected settings, never dump full ssh configuration. settings = dict(line.split(None, 1) for line in ssh.output.splitlines() if len(line.split(None, 1)) == 2) selected = {key: settings[key] for key in ("port", "permitrootlogin", "passwordauthentication", "pubkeyauthentication") if key in settings} risk = settings.get("permitrootlogin") == "yes" or settings.get("passwordauthentication") == "yes" ssh_complete = all(key in settings for key in ("permitrootlogin", "passwordauthentication")) add(Result("ssh", "unknown" if ssh.code != 0 else "warn" if risk else "unknown", t("SSH 配置"), (t(ssh.reason) or t("读取失败")) if ssh.code != 0 else t("允许 root 登录或密码认证") if risk else t("SSH 输出缺少关键字段,无法完成风险判断") if not ssh_complete else t("未发现这两项宽松设置"), "\n".join(line for line in ssh.output.splitlines() if line.split() and line.split()[0] in selected) if ssh.code == 0 else "", t("sshd -T 读取默认配置;权限不足、配置错误或 SSH 运行目录尚未创建均可能导致失败。结果可能与运行中服务或 Match 条件不同。"))) firewall = [] any_rules = False complete = True for args in (("ufw", "status"), ("nft", "list", "ruleset"), ("iptables", "-S")): cmd = host.run(*args) if cmd.code != 0: complete = False firewall.append(f"{args[0]}: {t(cmd.reason) or t('读取失败')}") else: meaningful = bool(cmd.output.strip()) and not cmd.output.strip().startswith("Status: inactive") any_rules |= meaningful firewall.append(f"{args[0]}: " + t("已读取;") + (t("存在状态或规则信息") if meaningful else t("未发现活动状态或规则"))) add(Result("firewall", "unknown", t("本机防火墙"), t("发现防火墙状态或规则;未判断规则是否允许目标端口") if any_rules else t("没有足够证据确认防火墙保护状态"), "\n".join(firewall), t("部分工具不可用或权限不足。") if not complete else t("规则存在不等于配置安全。"))) active = host.run("systemctl", "is-active", "sing-box.service") enabled = host.run("systemctl", "is-enabled", "sing-box.service") running = active.code == 0 and active.output.strip() == "active" failed = active.output.strip() == "failed" add(Result("service", "warn" if failed else "pass" if running else "unknown", t("sing-box 服务"), t("sing-box 服务处于 failed 状态,需要检查启动失败原因") if failed else t("服务处于 active") if running else t("未确认 sing-box 服务运行;新系统尚未安装属于正常情况"), f"active: {active.output.strip() or t(active.reason)}\nenabled: {enabled.output.strip() or t(enabled.reason)}")) startup = enabled.output.strip() startup_ok = enabled.code == 0 and startup == "enabled" startup_warn = startup in {"disabled", "masked", "enabled-runtime", "masked-runtime"} add(Result("service_startup", "pass" if startup_ok else "warn" if startup_warn else "unknown", t("sing-box 开机启动"), t("已配置持久开机启动") if startup_ok else t("未配置持久开机启动或服务被屏蔽,重启后可能不可用") if startup_warn else t("无法确认 sing-box 的持久开机启动状态"), startup or t(enabled.reason))) return report(results, port, lang) def report(results, port, lang="zh"): document={"schema_version": 1, "language": lang, "tool_version": __version__, "generated_at": datetime.now(timezone.utc).isoformat(), "mode": "read-only", "target_port": port, "checks": [asdict(result) for result in results]} from .schema import normalize_report normalize_report(document) return document