From 1336b9efb47889cfee270b501dd5839708834d2a Mon Sep 17 00:00:00 2001 From: RoyirinRosmontis Date: Sun, 23 Aug 2026 12:37:36 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD=EF=BC=9A?= =?UTF-8?q?=20-=20nodes.json=20=E6=9C=AC=E5=9C=B0=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20Windows=20DPAPI=20=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=EF=BC=88=E6=9C=AC=E6=9C=BA=E6=9C=AC=E7=94=A8=E6=88=B7=E5=8F=AF?= =?UTF-8?q?=E8=A7=A3=EF=BC=8C=E9=9D=9E=20Windows=20=E6=98=8E=E6=96=87?= =?UTF-8?q?=EF=BC=9B=E8=AF=BB=E5=8F=96=E5=85=BC=E5=AE=B9=E6=98=8E=E6=96=87?= =?UTF-8?q?/=E5=AF=86=E6=96=87=EF=BC=8C=E6=8D=9F=E5=9D=8F=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E5=AE=89=E5=85=A8=E9=99=8D=E7=BA=A7=EF=BC=89=20-=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20others/nodes=5Ftool=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=EF=BC=9A=E5=8A=A0=E5=AF=86=E5=AF=BC=E5=85=A5=20/=20=E8=A7=A3?= =?UTF-8?q?=E5=AF=86=E6=9F=A5=E7=9C=8B=20/=20=E6=98=BE=E7=A4=BA=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E8=B7=AF=E5=BE=84=20-=20=E7=A7=BB=E9=99=A4=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=9B=AE=E5=BD=95=E4=B8=AD=E7=9A=84=20nodes.json=20?= =?UTF-8?q?=E4=B8=8E=20PAC=20=E6=95=B0=E6=8D=AE=E6=96=87=E4=BB=B6=EF=BC=88?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=8F=82=E4=B8=8E=E6=89=93=E5=8C=85=EF=BC=9A?= =?UTF-8?q?nodes=20=E5=AD=98=20%LOCALAPPDATA%=EF=BC=8CPAC=20=E7=94=B1=20CD?= =?UTF-8?q?N=20=E6=8B=89=E5=8F=96=EF=BC=89=EF=BC=9BPAC=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E8=84=9A=E6=9C=AC=E4=B8=8E=E8=B7=AF=E7=94=B1=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=94=B9=E4=BB=8E=20CDN=20=E8=8E=B7=E5=8F=96=E6=95=B0?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lite/proxy_lite.py | 88 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/lite/proxy_lite.py b/lite/proxy_lite.py index 1a869f8..343d47f 100644 --- a/lite/proxy_lite.py +++ b/lite/proxy_lite.py @@ -84,13 +84,14 @@ def _load_nodes(): if path is None: log_red("nodes.json 缺失,无法加载节点配置") return _NODES - data = json.loads(Path(path).read_text("utf-8")) + data = json.loads(_nodes_decrypt(Path(path).read_bytes())) if not isinstance(data, list): raise ValueError("nodes.json 顶层应为节点数组") _NODES = data log(f"已加载节点配置: {len(_NODES)} 个 ({path})") if data and not str(data[0].get("domain") or "").strip(): - log_red(f"节点未填写域名,请编辑 {path} 后重启程序") + log_red("节点未填写域名:请配置 config.json 的 nodes_url 在线更新," + "或使用 others/nodes_tool 导入节点配置") except Exception as e: log_red(f"nodes.json 加载失败: {e}") return _NODES @@ -584,16 +585,91 @@ def _pac_write(name, data): tmp.write_text(json.dumps(data, ensure_ascii=False), "utf-8") os.replace(tmp, _USER_DIR / f"{name}.json") +# ── nodes.json 加密存储(Windows DPAPI,本机本用户可解) ───────────── +# 非 Windows(dev/测试)不加密,读写明文;读取对密文/明文都兼容。 + +class _DPAPI_BLOB(ctypes.Structure): + _fields_ = [("cbData", ctypes.c_uint32), + ("pbData", ctypes.POINTER(ctypes.c_char))] + + +def _dpapi_protect(data: bytes) -> bytes: + """DPAPI 加密 CryptProtectData(无附加熵,用户+机器绑定)。""" + crypt32 = ctypes.WinDLL("crypt32") + kernel32 = ctypes.WinDLL("kernel32") + crypt32.CryptProtectData.argtypes = [ctypes.POINTER(_DPAPI_BLOB), ctypes.c_wchar_p, + ctypes.POINTER(_DPAPI_BLOB), ctypes.c_void_p, + ctypes.c_void_p, ctypes.c_uint32, + ctypes.POINTER(_DPAPI_BLOB)] + crypt32.CryptProtectData.restype = ctypes.c_bool + kernel32.LocalFree.argtypes = [ctypes.c_void_p] + + buf = ctypes.create_string_buffer(data, len(data)) + blob_in = _DPAPI_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))) + blob_out = _DPAPI_BLOB() + if not crypt32.CryptProtectData(ctypes.byref(blob_in), "RRPP nodes.json", None, + None, None, 0, ctypes.byref(blob_out)): + raise OSError("CryptProtectData 调用失败") + try: + return ctypes.string_at(blob_out.pbData, blob_out.cbData) + finally: + kernel32.LocalFree(blob_out.pbData) + + +def _dpapi_unprotect(data: bytes) -> bytes: + """DPAPI 解密 CryptUnprotectData。""" + crypt32 = ctypes.WinDLL("crypt32") + kernel32 = ctypes.WinDLL("kernel32") + crypt32.CryptUnprotectData.argtypes = [ctypes.POINTER(_DPAPI_BLOB), ctypes.POINTER(ctypes.c_wchar_p), + ctypes.POINTER(_DPAPI_BLOB), ctypes.c_void_p, + ctypes.c_void_p, ctypes.c_uint32, + ctypes.POINTER(_DPAPI_BLOB)] + crypt32.CryptUnprotectData.restype = ctypes.c_bool + kernel32.LocalFree.argtypes = [ctypes.c_void_p] + + buf = ctypes.create_string_buffer(data, len(data)) + blob_in = _DPAPI_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))) + descr = ctypes.c_wchar_p() + blob_out = _DPAPI_BLOB() + if not crypt32.CryptUnprotectData(ctypes.byref(blob_in), ctypes.byref(descr), None, + None, None, 0, ctypes.byref(blob_out)): + raise OSError("CryptUnprotectData 调用失败") + try: + return ctypes.string_at(blob_out.pbData, blob_out.cbData) + finally: + kernel32.LocalFree(blob_out.pbData) + if descr.value: + kernel32.LocalFree(descr) + + +def _nodes_encrypt(text: str) -> bytes: + """加密 nodes.json 内容;非 Windows 环境明文(dev/测试)。""" + if os.name != "nt": + return text.encode("utf-8") + return _dpapi_protect(text.encode("utf-8")) + + +def _nodes_decrypt(blob: bytes) -> str: + """解密 nodes.json;解密失败回退按明文解析(兼容旧文件与在线拉取写入的明文)。""" + if os.name != "nt": + return blob.decode("utf-8") + try: + return _dpapi_unprotect(blob).decode("utf-8") + except Exception: + return blob.decode("utf-8") + + def _user_init_local(): - """初始化用户数据目录(同步):建目录,nodes.json 缺失时写入空模板。""" + """初始化用户数据目录(同步):建目录,nodes.json 缺失时写入加密空模板。""" _USER_DIR.mkdir(parents=True, exist_ok=True) if _user_path("nodes.json") is None: import json template = [{"domain": "", "port": 443, "country": "", "username": "", "password": "", "remark": ""}] - (_USER_DIR / "nodes.json").write_text( - json.dumps(template, ensure_ascii=False, indent=2) + "\n", "utf-8") - log_red(f"已创建 {_USER_DIR / 'nodes.json'} 模板,请填写节点配置后重启程序") + (_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt( + json.dumps(template, ensure_ascii=False, indent=2) + "\n")) + log_red(f"已创建 {_USER_DIR / 'nodes.json'}(加密存储),请配置 config.json 的 " + f"nodes_url 在线更新,或使用 others/nodes_tool 导入节点配置") def _user_init_remote(base_url=None, nodes_url=None): """检查并更新 PAC 数据与节点配置(后台线程):