重构:模块化 RRPP Lite 并更新入口

This commit is contained in:
RoyirinRosmontis
2026-09-16 23:13:28 +08:00
parent c37d8af821
commit 02d36c72f7
35 changed files with 2286 additions and 1079 deletions
+1
View File
@@ -0,0 +1 @@
"""RRPP Lite 的无 GUI 核心逻辑。"""
+91
View File
@@ -0,0 +1,91 @@
"""本地转发器的无状态报文解析与改写函数。"""
from urllib.parse import urlsplit
HTTP_METHODS = (
b"CONNECT ", b"GET ", b"POST ", b"PUT ", b"DELETE ",
b"HEAD ", b"OPTIONS ", b"PATCH ",
)
def starts_with_http_method(data: bytes) -> bool:
return any(data.startswith(method) for method in HTTP_METHODS)
def inject_proxy_auth(data: bytes, basic_auth: str) -> bytes:
"""为完整 HTTP 代理请求注入上游 Basic Authorization。"""
if not starts_with_http_method(data):
return data
end = data.find(b"\r\n\r\n")
if end == -1:
return data
head = data[:end]
first_end = head.find(b"\r\n")
request_line = head if first_end == -1 else head[:first_end]
headers = b"" if first_end == -1 else head[first_end + 2:]
for line in headers.split(b"\r\n"):
if b":" not in line:
continue
name = line[:line.index(b":")].strip().lower()
if name == b"authorization":
return data
auth_line = f"Authorization: Basic {basic_auth}\r\n".encode()
return request_line + b"\r\n" + auth_line + headers + data[end:]
def parse_connect_authority(first_line: str):
"""从 CONNECT 首行提取 ``(host, port)``,失败时返回 ``None``。"""
parts = first_line.split(" ")
if len(parts) < 2:
return None
authority = parts[1]
if authority.startswith("[") and "]" in authority:
try:
host = authority[1:authority.index("]")]
tail = authority[authority.index("]") + 1:]
if tail.startswith(":") and tail[1:].isdigit():
return host, int(tail[1:])
except ValueError:
pass
if ":" not in authority:
return None
host, _, port_s = authority.rpartition(":")
if not host or not port_s.isdigit():
return None
return host, int(port_s)
def parse_http_absolute(first_line: str):
"""从明文 HTTP 代理首行提取 ``(host, port)``,失败时返回 ``None``。"""
parts = first_line.split(" ")
if len(parts) < 2 or not parts[1].lower().startswith(("http://", "https://")):
return None
parsed = urlsplit(parts[1])
if not parsed.hostname:
return None
port = parsed.port or (443 if parsed.scheme == "https" else 80)
return parsed.hostname, port
def force_http_close(buffer: bytes) -> bytes:
"""直连明文 HTTP 时强制 Connection: close,避免错复用连接。"""
end = buffer.find(b"\r\n\r\n")
if end == -1:
return buffer
head, tail = buffer[:end], buffer[end:]
lines = head.split(b"\r\n")
out = [lines[0]]
conn_seen = False
for line in lines[1:]:
low = line.lower()
if low.startswith(b"connection:"):
out.append(b"Connection: close")
conn_seen = True
elif low.startswith(b"proxy-connection:"):
continue
else:
out.append(line)
if not conn_seen:
out.append(b"Connection: close")
return b"\r\n".join(out) + tail
+82
View File
@@ -0,0 +1,82 @@
"""路由、白名单和转发器共用的网络基础函数。"""
import functools
import ipaddress
import socket
import threading
import time
def ttl_cache(seconds: int, maxsize: int = 16384):
"""带 TTL 的小型线程安全缓存,并保留 ``cache_clear`` 兼容接口。"""
def decorator(function):
store = {}
lock = threading.Lock()
@functools.wraps(function)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
now = time.time()
with lock:
hit = store.get(key)
if hit and now - hit[0] < seconds:
return hit[1]
value = function(*args, **kwargs)
with lock:
store[key] = (now, value)
if len(store) > maxsize:
cutoff = now - seconds
for cached_key, (timestamp, _) in list(store.items()):
if timestamp < cutoff:
del store[cached_key]
if len(store) > maxsize:
store.clear()
return value
def cache_clear():
with lock:
store.clear()
wrapper.cache_clear = cache_clear
return wrapper
return decorator
def is_private_ipv4_int(value: int) -> bool:
"""整数形式 IPv4 是否属于常见私网、回环或链路本地段。"""
return (
(value & 0xFF000000) == 0x0A000000
or (value & 0xFFF00000) == 0xAC100000
or (value & 0xFFFF0000) == 0xC0A80000
or (value & 0xFF000000) == 0x7F000000
or (value & 0xFFFF0000) == 0xA9FE0000
)
def resolve_host_ips(host: str):
"""解析主机名的 IPv4/IPv6 集合;字面量 IP 返回自身,失败返回空集合。"""
try:
return {ipaddress.ip_address(host)}
except ValueError:
pass
try:
return {
ipaddress.ip_address(address[4][0])
for address in socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
}
except Exception:
return set()
def is_private_host(host: str) -> bool:
"""目标是否命中私网、回环或 IPv6 ULA/链路本地地址。"""
for address in resolve_host_ips(host):
if address.version == 4 and is_private_ipv4_int(int(address)):
return True
if address.version == 6 and (
address.is_loopback
or address.is_link_local
or address in ipaddress.ip_network("fc00::/7")
):
return True
return False
+36
View File
@@ -0,0 +1,36 @@
"""节点订阅的认证请求协议。"""
import base64
import json
import urllib.error
import urllib.request
class NodeAuthError(Exception):
"""订阅服务拒绝请求时携带的 HTTP 状态和用户可读原因。"""
def __init__(self, message, code=0):
super().__init__(message)
self.code = code
def b64url(value: str) -> str:
"""SSO 查询参数使用的无 padding URL-safe Base64。"""
return base64.urlsafe_b64encode(str(value).encode("utf-8")).decode("ascii").rstrip("=")
def fetch_nodes_auth(url, user, password, timeout=10):
"""直连拉取需 SSO 查询参数认证的 nodes.json。"""
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
separator = "&" if "?" in url else "?"
request_url = f"{url}{separator}user={b64url(user)}&password={b64url(password)}"
try:
with opener.open(request_url, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
body = ""
try:
body = error.read().decode("utf-8", "replace").strip()
except Exception:
pass
raise NodeAuthError(body or f"请求被拒绝 (HTTP {error.code})", error.code) from None
+95
View File
@@ -0,0 +1,95 @@
"""节点与登录凭据的本机加密存储。"""
import ctypes
import json
import os
class _DpapiBlob(ctypes.Structure):
_fields_ = [
("cbData", ctypes.c_uint32),
("pbData", ctypes.POINTER(ctypes.c_char)),
]
class NodeSecretsStore:
"""使用 Windows DPAPI 保存节点和 SSO 凭据;非 Windows 保持明文兼容。"""
def __init__(self, user_dir_getter):
self._user_dir_getter = user_dir_getter
@staticmethod
def protect_bytes(data: bytes) -> bytes:
crypt32 = ctypes.WinDLL("crypt32")
kernel32 = ctypes.WinDLL("kernel32")
crypt32.CryptProtectData.argtypes = [
ctypes.POINTER(_DpapiBlob), ctypes.c_wchar_p, ctypes.POINTER(_DpapiBlob),
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_DpapiBlob),
]
crypt32.CryptProtectData.restype = ctypes.c_bool
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
buffer = ctypes.create_string_buffer(data, len(data))
blob_in = _DpapiBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char)))
blob_out = _DpapiBlob()
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)
@staticmethod
def unprotect_bytes(data: bytes) -> bytes:
crypt32 = ctypes.WinDLL("crypt32")
kernel32 = ctypes.WinDLL("kernel32")
crypt32.CryptUnprotectData.argtypes = [
ctypes.POINTER(_DpapiBlob), ctypes.POINTER(ctypes.c_wchar_p), ctypes.POINTER(_DpapiBlob),
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_DpapiBlob),
]
crypt32.CryptUnprotectData.restype = ctypes.c_bool
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
buffer = ctypes.create_string_buffer(data, len(data))
blob_in = _DpapiBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char)))
description = ctypes.c_wchar_p()
blob_out = _DpapiBlob()
if not crypt32.CryptUnprotectData(ctypes.byref(blob_in), ctypes.byref(description), 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 description.value:
kernel32.LocalFree(description)
def encrypt_text(self, text: str) -> bytes:
if os.name != "nt":
return text.encode("utf-8")
return self.protect_bytes(text.encode("utf-8"))
def decrypt_bytes(self, blob: bytes) -> str:
if os.name != "nt":
return blob.decode("utf-8")
try:
return self.unprotect_bytes(blob).decode("utf-8")
except Exception:
return blob.decode("utf-8")
def save_auth(self, username, password):
directory = self._user_dir_getter()
directory.mkdir(parents=True, exist_ok=True)
text = json.dumps({"username": username, "password": password}, ensure_ascii=False)
(directory / "auth.json").write_bytes(self.encrypt_text(text))
def load_auth(self):
try:
path = self._user_dir_getter() / "auth.json"
if not path.is_file():
return None
data = json.loads(self.decrypt_bytes(path.read_bytes()))
if not (isinstance(data, dict) and data.get("username") and data.get("password")):
return None
return data
except Exception:
return None
+53
View File
@@ -0,0 +1,53 @@
"""PAC 数据文件的网络传输与安全落盘。"""
import json
import os
import urllib.request
from pathlib import Path
def read_version(path):
"""读取 PAC JSON 的 version/date;无效文件返回空字符串。"""
try:
data = json.loads(Path(path).read_text("utf-8"))
return str(data.get("version") or ""), str(data.get("date") or "")
except Exception:
return "", ""
def no_proxy_opener():
"""创建忽略环境/系统代理的直连 HTTP opener。"""
return urllib.request.build_opener(urllib.request.ProxyHandler({}))
def fetch_json(url, timeout=15, tries=2):
"""以直连方式拉取 JSON;最后一次失败时保留原始异常。"""
opener = no_proxy_opener()
for attempt in range(tries):
try:
with opener.open(url, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except Exception:
if attempt == tries - 1:
raise
def validate_document(name, data):
"""校验各 PAC 文件的最小结构,避免错误响应覆盖有效缓存。"""
if not isinstance(data, dict):
return False
if name in ("pac_domains", "pac_direct_domains"):
return isinstance(data.get("domains"), list)
if name == "pac_ip_cn":
return isinstance(data.get("cidrs"), list)
return False
def atomic_write(directory, name, data):
"""将 PAC JSON 以 tmp + replace 原子写入指定用户目录。"""
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
target = directory / f"{name}.json"
temporary_path = directory / f"{name}.json.tmp"
temporary_path.write_text(json.dumps(data, ensure_ascii=False), "utf-8")
os.replace(temporary_path, target)
+81
View File
@@ -0,0 +1,81 @@
"""PAC 路由引擎使用的纯算法,不依赖文件、网络或 Qt。"""
import ipaddress
import re
def merge_ip_ranges(cidrs, version):
"""将指定 IP 版本的 CIDR 合并为整数闭区间,供二分查找。"""
ranges = []
for cidr in cidrs:
network = ipaddress.ip_network(str(cidr).strip(), strict=False)
if network.version == version:
ranges.append((int(network.network_address), int(network.broadcast_address)))
ranges.sort()
merged = []
for start, end in ranges:
if merged and start <= merged[-1][1] + 1:
if end > merged[-1][1]:
merged[-1] = (merged[-1][0], end)
else:
merged.append((start, end))
return merged
def build_suffix_trie(suffixes):
"""按反向域名标签建立 Trie,适合大规模后缀规则的 O(标签数) 匹配。"""
root = {}
for suffix in suffixes:
node = root
for label in reversed(suffix.split(".")):
node = node.setdefault(label, {})
node[""] = True
return root
def suffix_trie_matches(host: str, trie) -> bool:
"""判断主机名是否命中一个后缀 Trie。"""
node = trie
for label in reversed(host.rstrip(".").lower().split(".")):
node = node.get(label)
if node is None:
return False
if node.get(""):
return True
return bool(node and node.get(""))
def parse_domain_rules(raw_rules, seed_suffixes=()):
"""解析后缀、``full:`` 与 ``regexp:`` 三种 PAC 域名规则。
返回 ``(suffixes, exact, regexes, invalid_regex_count)``。调用方决定
这些规则是“强制代理”还是“明确直连”,从而避免解析层携带业务策略。
"""
suffixes = set(seed_suffixes)
exact = set()
regexes = []
invalid_regexes = 0
for raw in raw_rules or ():
raw_rule = str(raw).strip()
rule = raw_rule.lower()
if not raw_rule:
continue
if rule.startswith("full:"):
host = rule[len("full:"):].rstrip(".")
if host:
exact.add(host)
continue
if rule.startswith("regexp:"):
pattern = raw_rule[len("regexp:"):]
if not pattern:
invalid_regexes += 1
continue
try:
regexes.append(re.compile(pattern, re.IGNORECASE))
except re.error:
invalid_regexes += 1
continue
suffix = rule.lstrip("*.").rstrip(".")
if suffix:
suffixes.add(suffix)
return suffixes, frozenset(exact), tuple(regexes), invalid_regexes
+34
View File
@@ -0,0 +1,34 @@
"""用户设置的 JSON 存储实现。"""
import json
import os
class JsonSettingsStore:
"""读取与原子写回一个 JSON 对象。
``path_getter`` 在每次操作时调用,保证测试或运行时切换用户目录后不会
意外写到旧目录。
"""
def __init__(self, path_getter):
self._path_getter = path_getter
def load(self):
path = self._path_getter()
try:
if not path.is_file():
return {}
data = json.loads(path.read_text("utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def save(self, data):
path = self._path_getter()
path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = path.with_suffix(path.suffix + ".tmp")
temporary_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n", "utf-8"
)
os.replace(temporary_path, path)
+55
View File
@@ -0,0 +1,55 @@
"""线程安全的本地转发流量统计。"""
import threading
import time
class TrafficStats:
"""记录单次代理会话的上下行总量与采样速率。"""
def __init__(self, clock=time.time):
self._clock = clock
self._lock = threading.Lock()
self._up = 0
self._down = 0
self._prev_up = 0
self._prev_down = 0
self._prev_ts = 0.0
def add(self, up=0, down=0):
with self._lock:
self._up += up
self._down += down
def reset(self):
with self._lock:
self._up = self._down = 0
self._prev_up = self._prev_down = 0
self._prev_ts = self._clock()
def rate(self):
with self._lock:
now = self._clock()
if self._prev_ts > 0 and now > self._prev_ts:
elapsed = now - self._prev_ts
up_rate = int((self._up - self._prev_up) / elapsed)
down_rate = int((self._down - self._prev_down) / elapsed)
else:
up_rate = down_rate = 0
self._prev_up = self._up
self._prev_down = self._down
self._prev_ts = now
return up_rate, down_rate, self._up, self._down
def snapshot(self):
with self._lock:
return self._up, self._down
def format_bytes(value):
"""将字节数格式化为 UI 展示文本。"""
number = float(value)
for unit in ("B", "KB", "MB", "GB", "TB"):
if number < 1024 or unit == "TB":
return f"{int(number)} {unit}" if unit == "B" else f"{number:.1f} {unit}"
number /= 1024