重构:模块化 RRPP Lite 并更新入口
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""RRPP Lite 的无 GUI 核心逻辑。"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user