新增功能:

- nodes.json 本地存储改用 Windows DPAPI 加密(本机本用户可解,非 Windows 明文;读取兼容明文/密文,损坏内容安全降级)
- 新增 others/nodes_tool 工具:加密导入 / 解密查看 / 显示存储路径
- 移除项目目录中的 nodes.json 与 PAC 数据文件(不再参与打包:nodes 存 %LOCALAPPDATA%,PAC 由 CDN 拉取);PAC 更新脚本与路由测试改从 CDN 获取数据
This commit is contained in:
RoyirinRosmontis
2026-08-23 12:37:36 +08:00
parent 8fed0a1844
commit 1336b9efb4
+82 -6
View File
@@ -84,13 +84,14 @@ def _load_nodes():
if path is None: if path is None:
log_red("nodes.json 缺失,无法加载节点配置") log_red("nodes.json 缺失,无法加载节点配置")
return _NODES 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): if not isinstance(data, list):
raise ValueError("nodes.json 顶层应为节点数组") raise ValueError("nodes.json 顶层应为节点数组")
_NODES = data _NODES = data
log(f"已加载节点配置: {len(_NODES)} 个 ({path})") log(f"已加载节点配置: {len(_NODES)} 个 ({path})")
if data and not str(data[0].get("domain") or "").strip(): 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: except Exception as e:
log_red(f"nodes.json 加载失败: {e}") log_red(f"nodes.json 加载失败: {e}")
return _NODES return _NODES
@@ -584,16 +585,91 @@ def _pac_write(name, data):
tmp.write_text(json.dumps(data, ensure_ascii=False), "utf-8") tmp.write_text(json.dumps(data, ensure_ascii=False), "utf-8")
os.replace(tmp, _USER_DIR / f"{name}.json") 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(): def _user_init_local():
"""初始化用户数据目录(同步):建目录,nodes.json 缺失时写入空模板。""" """初始化用户数据目录(同步):建目录,nodes.json 缺失时写入加密空模板。"""
_USER_DIR.mkdir(parents=True, exist_ok=True) _USER_DIR.mkdir(parents=True, exist_ok=True)
if _user_path("nodes.json") is None: if _user_path("nodes.json") is None:
import json import json
template = [{"domain": "", "port": 443, "country": "", template = [{"domain": "", "port": 443, "country": "",
"username": "", "password": "", "remark": ""}] "username": "", "password": "", "remark": ""}]
(_USER_DIR / "nodes.json").write_text( (_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(
json.dumps(template, ensure_ascii=False, indent=2) + "\n", "utf-8") json.dumps(template, ensure_ascii=False, indent=2) + "\n"))
log_red(f"已创建 {_USER_DIR / 'nodes.json'} 模板,请填写节点配置后重启程序") 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): def _user_init_remote(base_url=None, nodes_url=None):
"""检查并更新 PAC 数据与节点配置(后台线程): """检查并更新 PAC 数据与节点配置(后台线程):