重构:模块化 RRPP Lite 并更新入口
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""管理用户数据目录里的 nodes.json(Windows DPAPI 加密存储)。
|
||||
|
||||
用法(在 Windows 上运行):
|
||||
python nodes_tool.py encrypt <明文nodes.json> # 加密导入到 %LOCALAPPDATA%\\RRPP
|
||||
python nodes_tool.py decrypt # 打印解密后的内容
|
||||
python nodes_tool.py path # 显示存储路径
|
||||
|
||||
注意:DPAPI 加密后只有「本机 + 本用户」能解密;文件拷到别的机器/账户无效。
|
||||
"""
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def user_data_dir():
|
||||
if os.name == "nt" and os.environ.get("LOCALAPPDATA"):
|
||||
return Path(os.environ["LOCALAPPDATA"]) / "RRPP"
|
||||
return Path.home() / "RRPP"
|
||||
|
||||
|
||||
class _DATA_BLOB(ctypes.Structure):
|
||||
_fields_ = [("cbData", ctypes.c_uint32),
|
||||
("pbData", ctypes.POINTER(ctypes.c_char))]
|
||||
|
||||
|
||||
def dpapi_protect(data: bytes) -> bytes:
|
||||
crypt32 = ctypes.WinDLL("crypt32")
|
||||
kernel32 = ctypes.WinDLL("kernel32")
|
||||
crypt32.CryptProtectData.argtypes = [ctypes.POINTER(_DATA_BLOB), ctypes.c_wchar_p,
|
||||
ctypes.POINTER(_DATA_BLOB), ctypes.c_void_p,
|
||||
ctypes.c_void_p, ctypes.c_uint32,
|
||||
ctypes.POINTER(_DATA_BLOB)]
|
||||
crypt32.CryptProtectData.restype = ctypes.c_bool
|
||||
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
||||
|
||||
buf = ctypes.create_string_buffer(data, len(data))
|
||||
blob_in = _DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
|
||||
blob_out = _DATA_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:
|
||||
crypt32 = ctypes.WinDLL("crypt32")
|
||||
kernel32 = ctypes.WinDLL("kernel32")
|
||||
crypt32.CryptUnprotectData.argtypes = [ctypes.POINTER(_DATA_BLOB), ctypes.POINTER(ctypes.c_wchar_p),
|
||||
ctypes.POINTER(_DATA_BLOB), ctypes.c_void_p,
|
||||
ctypes.c_void_p, ctypes.c_uint32,
|
||||
ctypes.POINTER(_DATA_BLOB)]
|
||||
crypt32.CryptUnprotectData.restype = ctypes.c_bool
|
||||
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
||||
|
||||
buf = ctypes.create_string_buffer(data, len(data))
|
||||
blob_in = _DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
|
||||
descr = ctypes.c_wchar_p()
|
||||
blob_out = _DATA_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 cmd_encrypt(src: str):
|
||||
plain = Path(src)
|
||||
if not plain.is_file():
|
||||
print(f"未找到 {plain}")
|
||||
sys.exit(1)
|
||||
data = json.loads(plain.read_text("utf-8"))
|
||||
if not (isinstance(data, list) and data and all(isinstance(n, dict) for n in data)):
|
||||
print("nodes.json 应为非空节点数组")
|
||||
sys.exit(1)
|
||||
out_dir = user_data_dir()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = out_dir / "nodes.json"
|
||||
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
||||
blob = dpapi_protect(text.encode("utf-8")) if os.name == "nt" else text.encode("utf-8")
|
||||
out.write_bytes(blob)
|
||||
print(f"已{'加密' if os.name == 'nt' else '写入(非Windows不加密)'}: {out}({len(data)} 个节点)")
|
||||
|
||||
|
||||
def cmd_decrypt():
|
||||
f = user_data_dir() / "nodes.json"
|
||||
if not f.is_file():
|
||||
print(f"未找到 {f}")
|
||||
sys.exit(1)
|
||||
blob = f.read_bytes()
|
||||
if os.name == "nt":
|
||||
try:
|
||||
print(dpapi_unprotect(blob).decode("utf-8"), end="")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"DPAPI 解密失败: {e}")
|
||||
print(blob.decode("utf-8"), end="")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
cmd = sys.argv[1]
|
||||
if cmd == "encrypt" and len(sys.argv) == 3:
|
||||
cmd_encrypt(sys.argv[2])
|
||||
elif cmd == "decrypt":
|
||||
cmd_decrypt()
|
||||
elif cmd == "path":
|
||||
print(user_data_dir())
|
||||
else:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,143 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""更新 pac_domains.json:从 v2fly/domain-list-community (geosite) 拉取境外服务
|
||||
分类,与现有手工条目合并,生成新的强制走隧道域名后缀表。
|
||||
|
||||
用法:
|
||||
python update_pac_domains.py # 拉取并更新(先自动备份)
|
||||
python update_pac_domains.py --dry-run # 只统计,不写文件
|
||||
|
||||
geosite 数据格式(data/<分类>,每行一条规则):
|
||||
include:xxx 引用其他分类(递归解析)
|
||||
example.com 匹配域名及其子域(与我们的后缀匹配一致)
|
||||
+example.com 同上(additive)
|
||||
.example.com 仅精确匹配(不匹配子域)-> 跳过
|
||||
-example.com 排除 -> 跳过
|
||||
/regex/ 正则 -> 跳过
|
||||
example.com @cn 属性标签 -> 去掉标签
|
||||
无点的单标签(如 chrome)-> 无实际主机名会命中,跳过
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
BASE_URL = "https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/"
|
||||
# 需要强制走隧道的境外服务分类(geosite 分类名),可按需增删
|
||||
CATEGORIES = ["google", "apple", "telegram", "openai", "twitter", "facebook",
|
||||
"github", "netflix", "discord", "spotify", "whatsapp", "paypal"]
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
LITE_DIR = SCRIPT_DIR.parent.parent
|
||||
PAC_FILE = LITE_DIR / "pac_domains.json"
|
||||
BACKUP_DIR = LITE_DIR / "backups"
|
||||
# 本地 pac_domains.json 已不再随项目保留:缺失时从 CDN 拉当前版本作合并基底
|
||||
CDN_URL = "https://cdn.rrrosmontis.icu/RRPP/PAC/pac_domains.json"
|
||||
|
||||
|
||||
def fetch(cat, tries=2):
|
||||
for i in range(tries):
|
||||
try:
|
||||
with urllib.request.urlopen(BASE_URL + cat, timeout=20) as r:
|
||||
return r.read().decode("utf-8", "replace").splitlines()
|
||||
except Exception:
|
||||
if i == tries - 1:
|
||||
raise
|
||||
|
||||
|
||||
def resolve(cat, seen=None):
|
||||
"""递归展开 include:,返回原始行列表;循环引用用 seen 防死循环。"""
|
||||
seen = seen or set()
|
||||
if cat in seen:
|
||||
return []
|
||||
seen.add(cat)
|
||||
lines = []
|
||||
try:
|
||||
for raw in fetch(cat):
|
||||
line = raw.strip()
|
||||
if line.startswith("include:"):
|
||||
lines += resolve(line[len("include:"):].strip(), seen)
|
||||
else:
|
||||
lines.append(line)
|
||||
except Exception as e:
|
||||
print(f" [warn] 拉取 {cat} 失败: {e}")
|
||||
return lines
|
||||
|
||||
|
||||
def parse_domains(lines):
|
||||
"""过滤 geosite 行,只保留可映射为后缀匹配的纯域名。"""
|
||||
out = set()
|
||||
for line in lines:
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#") or s.startswith("/"):
|
||||
continue
|
||||
if s.startswith("-") or s.startswith("."):
|
||||
continue
|
||||
s = s.lstrip("+").split("@")[0].strip()
|
||||
if "." not in s:
|
||||
continue
|
||||
out.add(s.lower())
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="更新 pac_domains.json")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只统计,不写文件")
|
||||
args = parser.parse_args()
|
||||
|
||||
if PAC_FILE.is_file():
|
||||
cur = set(json.loads(PAC_FILE.read_text("utf-8"))["domains"])
|
||||
else:
|
||||
# 项目目录已不保留该文件:从 CDN 拉当前版本作合并基底(保留其中的手工条目)
|
||||
print(f"本地未找到 {PAC_FILE},从 CDN 拉取当前版本作为合并基底")
|
||||
try:
|
||||
with urllib.request.urlopen(CDN_URL, timeout=20) as r:
|
||||
cur = set(json.loads(r.read().decode("utf-8"))["domains"])
|
||||
except Exception as e:
|
||||
print(f"拉取失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"拉取 geosite 分类: {', '.join(CATEGORIES)}")
|
||||
merged = set()
|
||||
for cat in CATEGORIES:
|
||||
doms = parse_domains(resolve(cat))
|
||||
merged |= doms
|
||||
print(f" {cat:<12} -> {len(doms):>5} 条")
|
||||
|
||||
new = merged - cur
|
||||
kept = cur & merged
|
||||
only_cur = cur - merged
|
||||
print(f"\n合并后总数: {len(merged | cur)}(geosite {len(merged)} + 现有 {len(cur)})")
|
||||
print(f"新增 {len(new)},与现有重叠 {len(kept)},现有手工条目 {len(only_cur)} 条全部保留")
|
||||
|
||||
if args.dry_run:
|
||||
print("--dry-run: 未写文件")
|
||||
return
|
||||
|
||||
final = sorted(merged | cur)
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
comment = (
|
||||
f"强制走代理隧道的域名后缀(IP 智能分流的补充规则):命中这些域名的连接即使"
|
||||
f"解析到国内 IP 也强制走隧道。匹配规则:host == 条目 或 host 以 \".条目\" 结尾。"
|
||||
f"由 others/update_pac_domains/update_pac_domains.py 从 v2fly geosite "
|
||||
f"({', '.join(CATEGORIES)}) 生成于 {now},手工条目已保留;"
|
||||
f"客户端按文件内 version/date 从 CDN 在线更新到 %LOCALAPPDATA%\\RRPP。"
|
||||
)
|
||||
out = {"_comment": comment,
|
||||
"version": datetime.now().strftime("%Y%m%d%H%M"),
|
||||
"date": datetime.now().strftime("%Y-%m-%d"),
|
||||
"domains": final}
|
||||
|
||||
BACKUP_DIR.mkdir(exist_ok=True)
|
||||
bak = BACKUP_DIR / f"pac_domains.json.bak_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
bak.write_bytes(PAC_FILE.read_bytes())
|
||||
print(f"已备份: {bak}")
|
||||
|
||||
PAC_FILE.write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
||||
size = PAC_FILE.stat().st_size / 1024
|
||||
print(f"已写入: {PAC_FILE}({len(final)} 条,{size:.0f} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""更新 pac_ip_cn.json:从 17mon/china_ip_list 拉取中国 IPv4 段,写入带
|
||||
version/date 戳的本地 PAC 数据文件(客户端按 pac_manifest.json 比对在线更新)。
|
||||
|
||||
用法:
|
||||
python update_pac_ip_cn.py # 拉取并更新(先自动备份)
|
||||
python update_pac_ip_cn.py --dry-run # 只统计,不写文件
|
||||
"""
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
SRC_URL = "https://raw.githubusercontent.com/17mon/china_ip_list/master/china_ip_list.txt"
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
LITE_DIR = SCRIPT_DIR.parent.parent
|
||||
PAC_FILE = LITE_DIR / "pac_ip_cn.json"
|
||||
BACKUP_DIR = LITE_DIR / "backups"
|
||||
|
||||
|
||||
def fetch_lines(tries=2):
|
||||
for i in range(tries):
|
||||
try:
|
||||
with urllib.request.urlopen(SRC_URL, timeout=20) as r:
|
||||
return r.read().decode("utf-8", "replace").splitlines()
|
||||
except Exception:
|
||||
if i == tries - 1:
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="更新 pac_ip_cn.json")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只统计,不写文件")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"拉取: {SRC_URL}")
|
||||
cidrs, skipped = [], 0
|
||||
for line in fetch_lines():
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
net = ipaddress.ip_network(s, strict=False)
|
||||
if net.version == 4:
|
||||
cidrs.append(str(net))
|
||||
else:
|
||||
skipped += 1
|
||||
except ValueError:
|
||||
skipped += 1
|
||||
cidrs.sort(key=lambda c: (int(ipaddress.ip_network(c, strict=False).network_address),
|
||||
ipaddress.ip_network(c, strict=False).prefixlen))
|
||||
print(f"共 {len(cidrs)} 条 IPv4 CIDR,跳过 {skipped} 条")
|
||||
|
||||
if args.dry_run:
|
||||
print("--dry-run: 未写文件")
|
||||
return
|
||||
|
||||
now = datetime.now()
|
||||
comment = (
|
||||
f"中国 IPv4 地址段(CIDR)。智能分流依据:解析 IP 命中列表 → 直连,"
|
||||
f"未命中 → 走加密隧道。由 others/update_pac_ip_cn/update_pac_ip_cn.py "
|
||||
f"从 17mon/china_ip_list 生成于 {now.strftime('%Y-%m-%d %H:%M')};"
|
||||
f"客户端按文件内 version/date 从 CDN 在线更新到 %LOCALAPPDATA%\\RRPP。"
|
||||
)
|
||||
out = {"_comment": comment,
|
||||
"version": now.strftime("%Y%m%d%H%M"),
|
||||
"date": now.strftime("%Y-%m-%d"),
|
||||
"cidrs": cidrs}
|
||||
|
||||
BACKUP_DIR.mkdir(exist_ok=True)
|
||||
bak = BACKUP_DIR / f"pac_ip_cn.json.bak_{now.strftime('%Y%m%d_%H%M%S')}"
|
||||
if PAC_FILE.is_file():
|
||||
bak.write_bytes(PAC_FILE.read_bytes())
|
||||
print(f"已备份: {bak}")
|
||||
|
||||
PAC_FILE.write_text(json.dumps(out, ensure_ascii=False, indent=1) + "\n", "utf-8")
|
||||
size = PAC_FILE.stat().st_size / 1024
|
||||
print(f"已写入: {PAC_FILE}({len(cidrs)} 条,{size:.0f} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user