Files
VPSMaster/proxydoctor/checks.py

435 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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