Compare commits
18
Commits
999909190333
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a563cf30f3 | ||
|
|
813e54c919 | ||
|
|
6f2e640881 | ||
|
|
fa45751d33 | ||
|
|
b44d50ee4d | ||
|
|
1cc4da8318 | ||
|
|
8809e40ec1 | ||
|
|
375153b810 | ||
|
|
9a64853db4 | ||
|
|
bdd9d3498d | ||
|
|
41204df78a | ||
|
|
d0e739eff1 | ||
|
|
e406d614b7 | ||
|
|
357fd26a49 | ||
|
|
9d5bc527d3 | ||
|
|
2315735979 | ||
|
|
889dcb88e5 | ||
|
|
9224ad33fe |
@@ -1,17 +1,13 @@
|
||||
# Royirin Rosmontis Private Proxy
|
||||
Supported by Global Rosmanka Cloud Nexus Inc. —— https://rosmanka.rrrosmontis.icu
|
||||
|
||||
## 警告
|
||||
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本。common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
|
||||
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows 应用 (.exe)
|
||||
- 因为 Windows 系统的安全机制,Release 发布的所有程序都为 Pyinstaller 打包版本,故启动速度可能较慢,尤其是首次启动。相对而言,Nuitka 打包的版本启动较快,但可能仅能在自己电脑上运行。如果需要 Nuitka 版本,仓库提供了对应的打包程序,请自备虚拟环境
|
||||
- 自构建的客户端永远不再维护。推荐使用 Clash Verge 或其他软件结合服务器端配置使用。
|
||||
> 这些客户端使用了 HTTP 直接连接,具有流量识别的安全隐患。
|
||||
- 其中,common 版本过于过时,具有严重的安全问题和大量功能缺失。Lite 版本(版本号开头为 9999 的版本)则更为安全,但部分功能(TUN 模式)严重不可用,且似乎无法修复。
|
||||
- 在 Release 中保留了 Lite 版本的 Pyinstaller 构建产物,可能启动较慢。如果需要使用此客户端,可以考虑用 Nuitka 自行再次构建。
|
||||
- 本仓库中的构建产物与源码没有做 Linux 系统的适配。
|
||||
|
||||
### 如何区分 common 版本与 Lite 版本
|
||||
- Lite 版本的版本号前四位固定为 9999,后续为从月份开始的构建时间戳,而 common 版本的版本号为包含年份的完整的构建时间戳
|
||||
|
||||
## 服务器侧配置手册 (部分内容过时)
|
||||
- https://rrrosmontis.icu/articles/index.html?id=00005
|
||||
## 服务器侧配置手册
|
||||
- https://rrrosmontis.icu/articles/index.html?id=00005 (部分内容过时)
|
||||
- 更推荐参考本仓库 server_config 文件夹中的 md 文件手册
|
||||
|
||||
## 若要直接使用仓库客户端对应的服务
|
||||
请前往 https://sso.rrrosmontis.icu 注册 Rosmanka ID,然后与管理员商定使用细则
|
||||
royirin@rrrosmontis.icu
|
||||
@@ -26,6 +26,8 @@ call ..\.venv\Scripts\python.exe -m nuitka ^
|
||||
--include-data-files=runtime/LICENSE.tun2socks.txt=LICENSE.tun2socks.txt ^
|
||||
--include-data-files=runtime/LICENSE.wintun.txt=LICENSE.wintun.txt ^
|
||||
--include-data-files=runtime/README.txt=README.txt ^
|
||||
--include-data-files=runtime/tun2socks.exe=tun2socks.exe ^
|
||||
--include-data-files=runtime/wintun.dll=wintun.dll ^
|
||||
--windows-icon-from-ico=ico.ico ^
|
||||
--windows-console-mode=disable ^
|
||||
--company-name="Global Rosmanka Cloud Nexus Inc" ^
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "999909190333",
|
||||
"version": "999909190441",
|
||||
"pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
|
||||
"nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client",
|
||||
"update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest"
|
||||
|
||||
+29
-15
@@ -1056,7 +1056,7 @@ def _verify_proxy_connectivity(timeout=10, opener=None, cancel_event=None):
|
||||
response.read(1)
|
||||
_raise_if_start_cancelled(cancel_event)
|
||||
results[name] = True
|
||||
log(f" {name} 可达")
|
||||
log_detail(f" {name} 可达")
|
||||
except _StartCancelled:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -1064,7 +1064,7 @@ def _verify_proxy_connectivity(timeout=10, opener=None, cancel_event=None):
|
||||
# 这不是“开启失败”,也不能在稍后把界面重新切成已开启。
|
||||
_raise_if_start_cancelled(cancel_event)
|
||||
results[name] = False
|
||||
log(f" {name} 不可达: {e}")
|
||||
log_detail(f" {name} 不可达: {e}")
|
||||
if required:
|
||||
raise RuntimeError(f"境外隧道验证失败({name}):{e}") from e
|
||||
return results
|
||||
@@ -1116,7 +1116,7 @@ def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, sma
|
||||
svr.close()
|
||||
svr = None
|
||||
if attempt < 2:
|
||||
log(f"端口 {_TLS_LOCAL_PORT} 绑定失败: {e},1 秒后重试 ({attempt + 1}/3)")
|
||||
log_detail(f"端口 {_TLS_LOCAL_PORT} 绑定失败: {e},1 秒后重试 ({attempt + 1}/3)")
|
||||
time.sleep(1)
|
||||
if svr is None:
|
||||
raise last_err
|
||||
@@ -1145,7 +1145,7 @@ def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, sma
|
||||
if not quiet:
|
||||
route = "→".join(str(node.get("country") or "?") for node in (jump_chain or []))
|
||||
suffix = f" [{route}]" if route else ""
|
||||
log(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port}{suffix})")
|
||||
log_detail(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port}{suffix})")
|
||||
|
||||
def _inject_proxy_auth(data: bytes, proxy_node=None) -> bytes:
|
||||
"""在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。
|
||||
@@ -1312,6 +1312,15 @@ def _set_tun_dns_servers(value):
|
||||
data["tun_dns"] = list(parsed)
|
||||
_settings_save(data)
|
||||
|
||||
def _tun_ipv6_enabled():
|
||||
"""TUN IPv6 默认关闭,避免未验证的 IPv6 路径接管系统流量。"""
|
||||
return bool(_settings_load().get("tun_ipv6_enabled", False))
|
||||
|
||||
def _set_tun_ipv6_enabled(value):
|
||||
data = _settings_load()
|
||||
data["tun_ipv6_enabled"] = bool(value)
|
||||
_settings_save(data)
|
||||
|
||||
def _use_system_pac():
|
||||
"""只决定 Windows 系统代理是否使用 PAC;旧设置按原行为读取。"""
|
||||
data = _settings_load()
|
||||
@@ -2250,7 +2259,7 @@ def _connect_route(direct_target, allow_direct_fallback, remote_host, remote_por
|
||||
except OSError as e:
|
||||
if not allow_direct_fallback:
|
||||
raise
|
||||
log(f"[转发器] 国内直连 {direct_target[0]} 失败,回退上游隧道: {e}")
|
||||
log_detail(f"[转发器] 国内直连 {direct_target[0]} 失败,回退上游隧道: {e}")
|
||||
if jump_chain:
|
||||
if not use_tls:
|
||||
raise RuntimeError("跳板模式仅支持 TLS 上游")
|
||||
@@ -2372,7 +2381,7 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
|
||||
else:
|
||||
log_detail(f"[连接 #{connection_id}] 无法解析目标: {first_line or '空请求'},客户端={peer_text}")
|
||||
except Exception as e:
|
||||
log(f"[转发器] 读取首包失败: {e}")
|
||||
log_detail(f"[转发器] 读取首包失败: {e}")
|
||||
log_detail(f"[连接 #{connection_id}] 失败: 读取首包,目标={target_text},规则匹配={rule_match},错误={e}")
|
||||
client.close()
|
||||
return
|
||||
@@ -2405,7 +2414,7 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
|
||||
if via_tunnel:
|
||||
_traffic_add(up=len(buf))
|
||||
except Exception as e:
|
||||
log(f"[转发器] 连接目标失败: {e}")
|
||||
log_detail(f"[转发器] 连接目标失败: {e}")
|
||||
log_detail(f"[连接 #{connection_id}] 连接失败: 目标={target_text},规则匹配={rule_match},路径={route_summary},错误={e}")
|
||||
try:
|
||||
client.close()
|
||||
@@ -2632,6 +2641,7 @@ def _settings_services():
|
||||
get_port=lambda: _TLS_LOCAL_PORT,
|
||||
get_system_pac=_use_system_pac,
|
||||
get_dns=_tun_dns_servers,
|
||||
get_ipv6_enabled=_tun_ipv6_enabled,
|
||||
get_update_via_proxy=_update_via_proxy,
|
||||
get_skip_update_check=_skip_update_check,
|
||||
get_node_display_mode=_node_display_mode,
|
||||
@@ -2640,6 +2650,7 @@ def _settings_services():
|
||||
set_port=_set_local_port,
|
||||
set_system_pac=_set_use_system_pac,
|
||||
set_dns=_set_tun_dns_servers,
|
||||
set_ipv6_enabled=_set_tun_ipv6_enabled,
|
||||
set_update_via_proxy=_set_update_via_proxy,
|
||||
set_skip_update_check=_set_skip_update_check,
|
||||
set_node_display_mode=_set_node_display_mode,
|
||||
@@ -2680,7 +2691,8 @@ def _make_tun_backend(emit):
|
||||
engine = Path(BASE_DIR) / "runtime" / "tun2socks.exe"
|
||||
if not engine.is_file() or not (engine.parent / "wintun.dll").is_file():
|
||||
raise RuntimeError("单文件包中的 tun2socks.exe 或 wintun.dll 缺失")
|
||||
return TunBackend(_USER_DIR, engine_path=engine, log=emit, detail_log=emit)
|
||||
detail_emit = getattr(emit, "detail", emit)
|
||||
return TunBackend(_USER_DIR, engine_path=engine, log=emit, detail_log=detail_emit)
|
||||
|
||||
|
||||
def _start_tun_backend(backend, payload):
|
||||
@@ -2722,6 +2734,7 @@ def _start_tun_backend(backend, payload):
|
||||
bypass_lan=bypass_lan,
|
||||
route_rules=rules,
|
||||
dns_servers=payload["dns_servers"],
|
||||
ipv6_enabled=bool(payload.get("ipv6_enabled", False)),
|
||||
direct_target_resolver=direct_target,
|
||||
route_rule_resolver=user_rule,
|
||||
route_node_resolver=route_node,
|
||||
@@ -2748,6 +2761,7 @@ def _tun_start_payload(use_pac, bypass_lan):
|
||||
"route_rules": _route_rules(),
|
||||
"route_nodes": route_nodes,
|
||||
"dns_servers": _tun_dns_servers(),
|
||||
"ipv6_enabled": _tun_ipv6_enabled(),
|
||||
"bypass_lan": bool(bypass_lan),
|
||||
"smart": bool(use_pac),
|
||||
}
|
||||
@@ -2770,7 +2784,7 @@ class MainWindow(QWidget):
|
||||
super().__init__()
|
||||
self._proxy_set_by_us = False # 系统代理是否由本程序开启(退出时只清理自己设置的)
|
||||
self._mode = _proxy_mode()
|
||||
self._tun_controller = TunModeController(_tun_worker_command(), log)
|
||||
self._tun_controller = TunModeController(_tun_worker_command(), log, log_detail)
|
||||
self._quitting = False
|
||||
self._tray = None
|
||||
self._tray_menu = None
|
||||
@@ -3211,11 +3225,11 @@ class MainWindow(QWidget):
|
||||
_traffic_reset()
|
||||
current_node = _current_node()
|
||||
jump_chain = _jump_chain(current_node)
|
||||
log("TLS 多跳模式" if jump_chain else "TLS 直连模式")
|
||||
log_detail("TLS 多跳模式" if jump_chain else "TLS 直连模式")
|
||||
ep = _endpoint_domain()
|
||||
log(f" 目标域名: {ep}")
|
||||
log_detail(f" 目标域名: {ep}")
|
||||
if jump_chain:
|
||||
log(f" 跳板路径: {_jump_route_text(current_node)}")
|
||||
log_detail(f" 跳板路径: {_jump_route_text(current_node)}")
|
||||
# PAC 模式:全部流量进转发器,按域名表/解析 IP 智能分流
|
||||
use_internal_pac = _should_use_internal_pac(use_pac)
|
||||
if use_internal_pac:
|
||||
@@ -3236,7 +3250,7 @@ class MainWindow(QWidget):
|
||||
# 在内存中启动本地 PAC HTTP 服务,不往 EXE 目录写任何文件
|
||||
pac_port = _start_pac_server(pac_content)
|
||||
pac_url = f"http://127.0.0.1:{pac_port}/proxy.pac"
|
||||
log(f"使用 PAC: {pac_url}")
|
||||
log_detail(f"使用 PAC: {pac_url}")
|
||||
with self._proxy_state_lock:
|
||||
_raise_if_start_cancelled(cancel_event)
|
||||
proxy_on(pac_url=pac_url)
|
||||
@@ -3247,10 +3261,10 @@ class MainWindow(QWidget):
|
||||
proxy_on(bypass_lan=use_bypass_lan)
|
||||
self._proxy_set_by_us = True
|
||||
if use_internal_pac:
|
||||
log(f"使用内置 PAC:系统固定代理 127.0.0.1:{PROXY_PORT},转发器内部智能分流")
|
||||
log_detail(f"使用内置 PAC:系统固定代理 127.0.0.1:{PROXY_PORT},转发器内部智能分流")
|
||||
_raise_if_start_cancelled(cancel_event)
|
||||
if use_bypass_lan:
|
||||
log("不代理内网 IP:内网地址将直连")
|
||||
log_detail("不代理内网 IP:内网地址将直连")
|
||||
log("验证代理连通性...")
|
||||
_verify_proxy_connectivity(cancel_event=cancel_event)
|
||||
except Exception:
|
||||
|
||||
@@ -587,6 +587,20 @@ class Socks5Bridge:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _proxy_target(self, target: Address) -> Address:
|
||||
"""为上游 CONNECT 恢复唯一 DNS 域名,给 IPv6 目标提供 IPv4 回退。"""
|
||||
try:
|
||||
ipaddress.ip_address(target.host)
|
||||
except ValueError:
|
||||
return target
|
||||
domain = self._dns.lookup_ip(target.host)
|
||||
if not domain:
|
||||
return target
|
||||
self.log(
|
||||
f"[桥接] IPv6 目标回退域名解析: {target.host} -> {domain}:{target.port}"
|
||||
)
|
||||
return Address(domain, target.port)
|
||||
|
||||
def _open_target(self, target: Address) -> socket.socket:
|
||||
policy_host = self._policy_host(target.host)
|
||||
rule = self._route_rule(policy_host, target.host)
|
||||
@@ -594,12 +608,12 @@ class Socks5Bridge:
|
||||
if rule and str(rule.get("action") or "").lower() == "node":
|
||||
if route_node is None:
|
||||
raise ConnectionError("域名路由指定的节点不可用")
|
||||
return self._dialer(route_node).open(target)
|
||||
return self._dialer(route_node).open(self._proxy_target(target))
|
||||
direct = self._direct_target_for(target.host, target.port, policy_host)
|
||||
if direct is not None:
|
||||
self._ensure_direct_routes(direct[0], direct[1])
|
||||
return socket.create_connection(direct, 15)
|
||||
return self._dialer(self.node).open(target)
|
||||
return self._dialer(self.node).open(self._proxy_target(target))
|
||||
|
||||
def _tcp(self, client: socket.socket, target: Address):
|
||||
upstream = self._open_target(target)
|
||||
|
||||
@@ -27,6 +27,16 @@ class TunBackendError(RuntimeError):
|
||||
"""TUN 后端无法启动或停止。"""
|
||||
|
||||
|
||||
def _run_hidden(command, **kwargs):
|
||||
"""运行 Windows 网络命令时隐藏控制台窗口。
|
||||
|
||||
源码通过 test.bat 启动时这些子进程复用已有控制台;Nuitka GUI 没有父控制台,
|
||||
未设置 CREATE_NO_WINDOW 就会为每次 PowerShell 查询弹出一个窗口。
|
||||
"""
|
||||
kwargs.setdefault("creationflags", getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
return subprocess.run(command, **kwargs)
|
||||
|
||||
|
||||
class TunBackend:
|
||||
def __init__(self, user_dir: str | Path, engine_path: str | Path | None = None,
|
||||
log: Callable[[str], None] | None = None,
|
||||
@@ -53,6 +63,7 @@ class TunBackend:
|
||||
self._ipv6_address = "fd42:7272:5050::1"
|
||||
self._dns_configured = False
|
||||
self._tun_dns_servers = ("8.8.8.8", "1.1.1.1")
|
||||
self._ipv6_enabled = False
|
||||
self._bypass_lan = True
|
||||
self._cleanup_pending = False
|
||||
self._state_path = self.user_dir / "tun_state.json"
|
||||
@@ -109,7 +120,7 @@ class TunBackend:
|
||||
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
|
||||
"Sort-Object RouteMetric | Select-Object -First 1 -ExpandProperty InterfaceAlias)"]
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=5)
|
||||
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
|
||||
value = (result.stdout or "").strip().splitlines()
|
||||
if value and value[0].strip():
|
||||
return value[0].strip()
|
||||
@@ -126,7 +137,7 @@ class TunBackend:
|
||||
f"(Get-NetIPAddress -InterfaceAlias '{escaped}' -AddressFamily IPv4 "
|
||||
"-AddressState Preferred | Select-Object -First 1 -ExpandProperty IPAddress)"]
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=5)
|
||||
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
|
||||
value = (result.stdout or "").strip().splitlines()
|
||||
if value and value[0].strip():
|
||||
return value[0].strip()
|
||||
@@ -143,7 +154,7 @@ class TunBackend:
|
||||
"Sort-Object RouteMetric | Select-Object -First 1 "
|
||||
"NextHop,InterfaceIndex | ConvertTo-Json -Compress"]
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=5)
|
||||
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
|
||||
data = json.loads((result.stdout or "").strip())
|
||||
if isinstance(data, dict):
|
||||
return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "")
|
||||
@@ -160,7 +171,7 @@ class TunBackend:
|
||||
"Sort-Object RouteMetric | Select-Object -First 1 "
|
||||
"NextHop,InterfaceIndex | ConvertTo-Json -Compress"]
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=5)
|
||||
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
|
||||
data = json.loads((result.stdout or "").strip())
|
||||
if isinstance(data, dict):
|
||||
return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "")
|
||||
@@ -181,7 +192,7 @@ class TunBackend:
|
||||
"$_.IPAddress -notlike 'fd*' }).IPAddress | ConvertTo-Json -Compress",
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=5)
|
||||
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
|
||||
if result.returncode:
|
||||
return None
|
||||
value = json.loads((result.stdout or "").strip() or "[]")
|
||||
@@ -241,7 +252,7 @@ class TunBackend:
|
||||
if self._physical_gateway:
|
||||
command.append(self._physical_gateway)
|
||||
command.extend(["METRIC", "1", "IF", self._physical_ifindex])
|
||||
result = subprocess.run(command, capture_output=True, text=True,
|
||||
result = _run_hidden(command, capture_output=True, text=True,
|
||||
encoding="mbcs", errors="replace", timeout=15)
|
||||
if result.returncode:
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
@@ -251,6 +262,10 @@ class TunBackend:
|
||||
continue
|
||||
self._node_routes.append(address)
|
||||
self._write_state()
|
||||
if candidates_v6 and not self._ipv6_enabled:
|
||||
if not candidates_v4:
|
||||
raise TunBackendError("IPv6 模式已关闭,代理节点仅包含 IPv6 地址")
|
||||
candidates_v6 = []
|
||||
if candidates_v6 and not self._physical_ipv6_ifindex:
|
||||
# 双栈节点仍可使用 IPv4 地址;只有 IPv6-only 节点需要物理 IPv6 出口。
|
||||
if candidates_v4:
|
||||
@@ -270,7 +285,7 @@ class TunBackend:
|
||||
if self._physical_ipv6_gateway and self._physical_ipv6_gateway != "::":
|
||||
command.append(f"nexthop={self._physical_ipv6_gateway}")
|
||||
command.append("metric=1")
|
||||
result = subprocess.run(["netsh", *command], capture_output=True, text=True,
|
||||
result = _run_hidden(["netsh", *command], capture_output=True, text=True,
|
||||
encoding="mbcs", errors="replace", timeout=15)
|
||||
if result.returncode:
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
@@ -289,6 +304,8 @@ class TunBackend:
|
||||
except ValueError:
|
||||
return
|
||||
if parsed.version == 6:
|
||||
if not self._ipv6_enabled:
|
||||
raise TunBackendError("IPv6 模式未启用,无法添加 IPv6 物理路由")
|
||||
normalized = parsed.compressed
|
||||
if normalized in self._node_ipv6_routes:
|
||||
return
|
||||
@@ -303,7 +320,7 @@ class TunBackend:
|
||||
if self._physical_gateway:
|
||||
command.append(self._physical_gateway)
|
||||
command.extend(["METRIC", "1", "IF", self._physical_ifindex])
|
||||
result = subprocess.run(command, capture_output=True, text=True,
|
||||
result = _run_hidden(command, capture_output=True, text=True,
|
||||
encoding="mbcs", errors="replace", timeout=15)
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
if result.returncode:
|
||||
@@ -326,7 +343,7 @@ class TunBackend:
|
||||
|
||||
def _cleanup_netsh(self, args: list[str]) -> bool:
|
||||
try:
|
||||
result = subprocess.run(["netsh", *args], capture_output=True, text=True,
|
||||
result = _run_hidden(["netsh", *args], capture_output=True, text=True,
|
||||
encoding="mbcs", errors="replace", timeout=15)
|
||||
except Exception as exc:
|
||||
self.detail_log(f"[TUN] 清理 netsh 失败: {exc}")
|
||||
@@ -341,7 +358,7 @@ class TunBackend:
|
||||
remaining: list[str] = []
|
||||
for address in self._node_routes:
|
||||
try:
|
||||
result = subprocess.run(["route", "DELETE", address], capture_output=True,
|
||||
result = _run_hidden(["route", "DELETE", address], capture_output=True,
|
||||
text=True, encoding="mbcs", errors="replace", timeout=15)
|
||||
except Exception as exc:
|
||||
self.detail_log(f"[TUN] 清理节点路由失败 ({address}): {exc}")
|
||||
@@ -358,7 +375,7 @@ class TunBackend:
|
||||
if self._physical_ipv6_ifindex:
|
||||
command.append(f"interface={self._physical_ipv6_ifindex}")
|
||||
try:
|
||||
result = subprocess.run(["netsh", *command], capture_output=True, text=True,
|
||||
result = _run_hidden(["netsh", *command], capture_output=True, text=True,
|
||||
encoding="mbcs", errors="replace", timeout=15)
|
||||
except Exception as exc:
|
||||
self.detail_log(f"[TUN] 清理 IPv6 节点路由失败 ({address}): {exc}")
|
||||
@@ -372,7 +389,7 @@ class TunBackend:
|
||||
return not remaining and not remaining_v6
|
||||
|
||||
def _netsh(self, args: list[str], *, ignore_error: bool = False) -> None:
|
||||
result = subprocess.run(["netsh", *args], capture_output=True, text=True,
|
||||
result = _run_hidden(["netsh", *args], capture_output=True, text=True,
|
||||
encoding="mbcs", errors="replace", timeout=15)
|
||||
if result.returncode and not ignore_error:
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
@@ -381,7 +398,7 @@ class TunBackend:
|
||||
def _configure_routes(self) -> None:
|
||||
for _ in range(20):
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
probe = _run_hidden(
|
||||
["powershell", "-NoProfile", "-Command",
|
||||
f"Get-NetAdapter -Name '{self._device}' -ErrorAction SilentlyContinue"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
@@ -400,14 +417,15 @@ class TunBackend:
|
||||
f"{self._device}", self._gateway, "metric=1"])
|
||||
self._route_added = True
|
||||
self._write_state()
|
||||
self._netsh(["interface", "ipv6", "set", "address",
|
||||
f"interface={self._device}", f"address={self._ipv6_address}",
|
||||
"store=active"])
|
||||
self._netsh(["interface", "ipv6", "add", "route", "prefix=::/0",
|
||||
f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
|
||||
"metric=1"])
|
||||
self._ipv6_route_added = True
|
||||
self._write_state()
|
||||
if self._ipv6_enabled:
|
||||
self._netsh(["interface", "ipv6", "set", "address",
|
||||
f"interface={self._device}", f"address={self._ipv6_address}",
|
||||
"store=active"])
|
||||
self._netsh(["interface", "ipv6", "add", "route", "prefix=::/0",
|
||||
f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
|
||||
"metric=1"])
|
||||
self._ipv6_route_added = True
|
||||
self._write_state()
|
||||
if not self._bypass_lan:
|
||||
# Windows 物理网卡通常已有更具体的私网路由;补同前缀的 TUN
|
||||
# 路由,确保“内网也代理”不会在进入 tun2socks 前被物理网卡截走。
|
||||
@@ -417,12 +435,13 @@ class TunBackend:
|
||||
f"{self._device}", self._gateway, "metric=1"])
|
||||
self._lan_routes.append(network)
|
||||
self._write_state()
|
||||
for network in ("fc00::/7",):
|
||||
self._netsh(["interface", "ipv6", "add", "route", f"prefix={network}",
|
||||
f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
|
||||
"metric=1"])
|
||||
self._lan_ipv6_routes.append(network)
|
||||
self._write_state()
|
||||
if self._ipv6_enabled:
|
||||
for network in ("fc00::/7",):
|
||||
self._netsh(["interface", "ipv6", "add", "route", f"prefix={network}",
|
||||
f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
|
||||
"metric=1"])
|
||||
self._lan_ipv6_routes.append(network)
|
||||
self._write_state()
|
||||
self._configure_dns()
|
||||
|
||||
def _configure_dns(self) -> None:
|
||||
@@ -430,7 +449,7 @@ class TunBackend:
|
||||
if os.name != "nt":
|
||||
return
|
||||
ipv4 = [item for item in self._tun_dns_servers if ":" not in item]
|
||||
ipv6 = [item for item in self._tun_dns_servers if ":" in item]
|
||||
ipv6 = [item for item in self._tun_dns_servers if ":" in item] if self._ipv6_enabled else []
|
||||
if ipv4:
|
||||
self._netsh([
|
||||
"interface", "ipv4", "set", "dnsservers",
|
||||
@@ -531,6 +550,7 @@ class TunBackend:
|
||||
"node_ipv6_routes": list(self._node_ipv6_routes),
|
||||
"route_added": bool(self._route_added),
|
||||
"ipv6_route_added": bool(self._ipv6_route_added),
|
||||
"ipv6_enabled": bool(self._ipv6_enabled),
|
||||
"dns_configured": bool(self._dns_configured),
|
||||
"dns_servers": list(self._tun_dns_servers),
|
||||
"cleanup_pending": bool(self._cleanup_pending),
|
||||
@@ -587,6 +607,7 @@ class TunBackend:
|
||||
self._gateway = str(state.get("gateway") or self._gateway)
|
||||
self._ipv6_address = str(state.get("ipv6_address") or self._ipv6_address)
|
||||
self._ipv6_gateway = str(state.get("ipv6_gateway") or self._ipv6_gateway)
|
||||
self._ipv6_enabled = bool(state.get("ipv6_enabled", bool(state.get("ipv6_route_added"))))
|
||||
self._physical_ipv6_gateway = str(state.get("physical_ipv6_gateway") or "")
|
||||
self._physical_ipv6_ifindex = str(state.get("physical_ipv6_ifindex") or "")
|
||||
self._route_added = bool(state.get("route_added"))
|
||||
@@ -625,6 +646,7 @@ class TunBackend:
|
||||
def start(self, node: dict[str, Any], *, bypass_lan: bool = True,
|
||||
route_rules: list[dict[str, Any]] | None = None,
|
||||
dns_servers: tuple[str, ...] | list[str] | None = None,
|
||||
ipv6_enabled: bool = False,
|
||||
direct_target_resolver: Callable[[str, str, int], tuple[str, int] | None] | None = None,
|
||||
route_rule_resolver: Callable[[str], dict[str, Any] | None] | None = None,
|
||||
route_node_resolver: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None,
|
||||
@@ -635,24 +657,30 @@ class TunBackend:
|
||||
if not self.is_admin():
|
||||
raise TunBackendError("启动虚拟网卡 TUN 需要管理员权限")
|
||||
self._bypass_lan = bool(bypass_lan)
|
||||
self._ipv6_enabled = bool(ipv6_enabled)
|
||||
if dns_servers is not None:
|
||||
cleaned = tuple(str(item).strip() for item in dns_servers if str(item).strip())
|
||||
if not cleaned:
|
||||
raise TunBackendError("自定义 DNS 不能为空")
|
||||
try:
|
||||
for item in cleaned:
|
||||
socket.inet_pton(socket.AF_INET, item)
|
||||
except OSError as exc:
|
||||
raise TunBackendError("当前 TUN 仅支持 IPv4 DNS 服务器") from exc
|
||||
address = ipaddress.ip_address(item)
|
||||
if address.is_unspecified or address.is_multicast:
|
||||
raise ValueError(item)
|
||||
except ValueError as exc:
|
||||
raise TunBackendError("自定义 DNS 必须是有效的 IPv4/IPv6 地址") from exc
|
||||
self._tun_dns_servers = cleaned[:3]
|
||||
engine = self.resolve_engine()
|
||||
interface = self._physical_interface()
|
||||
source_address = self._physical_source_address(interface)
|
||||
self._physical_gateway, self._physical_ifindex = self._physical_route_info()
|
||||
self._physical_ipv6_gateway, self._physical_ipv6_ifindex = self._physical_ipv6_route_info()
|
||||
if self._ipv6_enabled:
|
||||
self._physical_ipv6_gateway, self._physical_ipv6_ifindex = self._physical_ipv6_route_info()
|
||||
else:
|
||||
self._physical_ipv6_gateway = self._physical_ipv6_ifindex = ""
|
||||
if not self._physical_ifindex:
|
||||
raise TunBackendError("找不到物理默认路由,无法保证代理节点不进入 TUN 回环")
|
||||
if not self._physical_ipv6_ifindex:
|
||||
if self._ipv6_enabled and not self._physical_ipv6_ifindex:
|
||||
self.log("警告:未找到物理 IPv6 默认路由,IPv6 目标将仅尝试通过 IPv4 代理节点转发")
|
||||
try:
|
||||
proxy_addresses_by_node: dict[str, list[tuple]] = {}
|
||||
@@ -709,7 +737,7 @@ class TunBackend:
|
||||
# 因此 Windows 不需要 --interface;Linux/macOS 仍保留原行为。
|
||||
if interface and os.name != "nt":
|
||||
command.extend(["--interface", interface])
|
||||
self.log(f"本地 SOCKS5 桥接: 127.0.0.1:{socks_port}")
|
||||
self.detail_log(f"本地 SOCKS5 桥接: 127.0.0.1:{socks_port}")
|
||||
try:
|
||||
self._process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
|
||||
+36
-15
@@ -46,9 +46,11 @@ def _receive(reader) -> dict[str, Any]:
|
||||
class TunModeController:
|
||||
"""普通权限 GUI 仅在首次开启 TUN 时启动管理员工作进程。"""
|
||||
|
||||
def __init__(self, command: list[str], log: Callable[[str], None]):
|
||||
def __init__(self, command: list[str], log: Callable[[str], None],
|
||||
detail_log: Callable[[str], None] | None = None):
|
||||
self.command = command
|
||||
self.log = log
|
||||
self.detail_log = detail_log or log
|
||||
self._lock = threading.RLock()
|
||||
self._socket: socket.socket | None = None
|
||||
self._reader = None
|
||||
@@ -124,17 +126,23 @@ class TunModeController:
|
||||
def _request(self, command: str, payload: dict[str, Any] | None = None) -> Any:
|
||||
if self._socket is None or self._reader is None:
|
||||
raise TunModeError("TUN 工作进程未连接")
|
||||
_send(self._socket, {"command": command, "payload": payload or {}})
|
||||
while True:
|
||||
message = _receive(self._reader)
|
||||
if message.get("type") == "log":
|
||||
self.log(str(message.get("message") or ""))
|
||||
continue
|
||||
if message.get("type") != "result":
|
||||
raise TunModeError("TUN 工作进程响应无效")
|
||||
if not message.get("ok"):
|
||||
raise TunModeError(str(message.get("error") or "TUN 操作失败"))
|
||||
return message.get("value")
|
||||
try:
|
||||
_send(self._socket, {"command": command, "payload": payload or {}})
|
||||
while True:
|
||||
message = _receive(self._reader)
|
||||
if message.get("type") == "log":
|
||||
callback = self.detail_log if message.get("detail") else self.log
|
||||
callback(str(message.get("message") or ""))
|
||||
continue
|
||||
if message.get("type") != "result":
|
||||
raise TunModeError("TUN 工作进程响应无效")
|
||||
if not message.get("ok"):
|
||||
raise TunModeError(str(message.get("error") or "TUN 操作失败"))
|
||||
return message.get("value")
|
||||
except TunModeError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise TunModeError(f"TUN 工作进程连接中止: {exc}") from exc
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
@@ -187,15 +195,24 @@ def run_tun_worker(port: int, token: str, backend_factory,
|
||||
send_lock = threading.Lock()
|
||||
_send(connection, {"type": "hello", "token": token}, send_lock)
|
||||
|
||||
def emit(message: str) -> None:
|
||||
def emit(message: str, detail: bool = False) -> None:
|
||||
try:
|
||||
_send(connection, {"type": "log", "message": str(message)}, send_lock)
|
||||
_send(connection, {"type": "log", "message": str(message),
|
||||
"detail": bool(detail)}, send_lock)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
class _WorkerLogger:
|
||||
def __call__(self, message: str) -> None:
|
||||
emit(message)
|
||||
|
||||
def detail(self, message: str) -> None:
|
||||
emit(message, detail=True)
|
||||
|
||||
logger = _WorkerLogger()
|
||||
|
||||
backend = None
|
||||
try:
|
||||
backend = backend_factory(emit)
|
||||
while True:
|
||||
try:
|
||||
request = _receive(reader)
|
||||
@@ -206,6 +223,10 @@ def run_tun_worker(port: int, token: str, backend_factory,
|
||||
if command == "start":
|
||||
if not isinstance(request.get("payload"), dict):
|
||||
raise TunModeError("TUN 启动参数无效")
|
||||
# 延迟创建后端,使初始化失败也能通过控制协议返回具体原因,
|
||||
# 避免 GUI 只看到 Windows 的 10053 连接中止错误。
|
||||
if backend is None:
|
||||
backend = backend_factory(logger)
|
||||
start_backend(backend, request["payload"])
|
||||
value = None
|
||||
elif command == "stop":
|
||||
|
||||
@@ -933,6 +933,7 @@ class SettingsServices:
|
||||
get_port: object
|
||||
get_system_pac: object
|
||||
get_dns: object
|
||||
get_ipv6_enabled: object
|
||||
get_update_via_proxy: object
|
||||
get_skip_update_check: object
|
||||
get_node_display_mode: object
|
||||
@@ -941,6 +942,7 @@ class SettingsServices:
|
||||
set_port: object
|
||||
set_system_pac: object
|
||||
set_dns: object
|
||||
set_ipv6_enabled: object
|
||||
set_update_via_proxy: object
|
||||
set_skip_update_check: object
|
||||
set_node_display_mode: object
|
||||
@@ -1114,6 +1116,18 @@ class SettingsDialog(QDialog):
|
||||
dns_row.addWidget(self.ed_dns)
|
||||
left_col.addLayout(dns_row)
|
||||
|
||||
ipv6_row = QHBoxLayout()
|
||||
ipv6_label = QLabel("启用 IPv6 模式 (虚拟网卡)")
|
||||
ipv6_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||
ipv6_label.setToolTip("TUN 模式安装 IPv6 地址和默认路由;默认关闭")
|
||||
ipv6_row.addWidget(ipv6_label)
|
||||
ipv6_row.addStretch()
|
||||
get_ipv6_enabled = getattr(services, "get_ipv6_enabled", lambda: False)
|
||||
self.cb_ipv6 = ToggleSwitch(checked=bool(get_ipv6_enabled()))
|
||||
self.cb_ipv6.setToolTip("仅虚拟网卡模式使用;系统代理模式忽略此项")
|
||||
ipv6_row.addWidget(self.cb_ipv6)
|
||||
left_col.addLayout(ipv6_row)
|
||||
|
||||
update_row = QHBoxLayout()
|
||||
update_label = QLabel("使用代理更新")
|
||||
update_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||
@@ -1266,6 +1280,8 @@ class SettingsDialog(QDialog):
|
||||
self.ed_port.setText(str(self._services.get_port()))
|
||||
self.cb_system_pac.setChecked(self._services.get_system_pac())
|
||||
self.ed_dns.setText(", ".join(self._services.get_dns()))
|
||||
get_ipv6_enabled = getattr(self._services, "get_ipv6_enabled", lambda: False)
|
||||
self.cb_ipv6.setChecked(bool(get_ipv6_enabled()))
|
||||
self.cb_update_proxy.setChecked(self._services.get_update_via_proxy())
|
||||
self.cb_skip_update.setChecked(self._services.get_skip_update_check())
|
||||
self.btn_node_domain.setChecked(True)
|
||||
@@ -1287,6 +1303,8 @@ class SettingsDialog(QDialog):
|
||||
return
|
||||
self._services.set_port(port)
|
||||
self._services.set_system_pac(bool(self.cb_system_pac.isChecked()))
|
||||
set_ipv6_enabled = getattr(self._services, "set_ipv6_enabled", lambda _value: None)
|
||||
set_ipv6_enabled(bool(self.cb_ipv6.isChecked()))
|
||||
self._services.set_update_via_proxy(bool(self.cb_update_proxy.isChecked()))
|
||||
self._services.set_skip_update_check(bool(self.cb_skip_update.isChecked()))
|
||||
self._services.set_node_display_mode(
|
||||
|
||||
+305
-2
@@ -22,6 +22,7 @@
|
||||
- [9. systemd 服务文件](#9-systemd-服务文件)
|
||||
- [10. 启动顺序与验证清单](#10-启动顺序与验证清单)
|
||||
- [11. 目录结构与文件索引](#11-目录结构与文件索引)
|
||||
- [12. RRPP 订阅后端(PAC → 规则集下发)](#12-rrpp-订阅后端pac--规则集下发)
|
||||
|
||||
---
|
||||
|
||||
@@ -157,6 +158,110 @@ fail2ban-client status sshd # 确认 jail 在跑
|
||||
> - 只加 fail2ban、**不动 sshd 认证方式**(root 登录等现状保留)。
|
||||
> - 如需同时护 nginx CONNECT 的 401 爆破,可再加 nginx jail(凭据够强时可不做)。
|
||||
|
||||
### 2.6 系统级网络优化(KVM / LXC 差异)与 qdisc 持久化
|
||||
|
||||
> 模板:[sysctl/99-zz-rrpp-network.kvm.conf](sysctl/99-zz-rrpp-network.kvm.conf)(完整 VM,含 Azure 等)、
|
||||
> [sysctl/99-zz-rrpp-network.lxc.conf](sysctl/99-zz-rrpp-network.lxc.conf)(容器)
|
||||
> 落地位置统一为 `/etc/sysctl.d/99-zz-rrpp-network.conf`;
|
||||
> qdisc 伴侣:[systemd/rrpp-qdisc.service](systemd/rrpp-qdisc.service) + [scripts/rrpp-qdisc.sh](scripts/rrpp-qdisc.sh)。
|
||||
|
||||
```bash
|
||||
# KVM / 完整 VM
|
||||
install -m 644 sysctl/99-zz-rrpp-network.kvm.conf /etc/sysctl.d/99-zz-rrpp-network.conf
|
||||
# LXC / OpenVZ 容器
|
||||
install -m 644 sysctl/99-zz-rrpp-network.lxc.conf /etc/sysctl.d/99-zz-rrpp-network.conf
|
||||
# qdisc 持久化(仅完整 VM 有意义;容器内网卡是 noqueue,脚本会自动跳过)
|
||||
install -m 755 scripts/rrpp-qdisc.sh /usr/local/sbin/rrpp-qdisc.sh
|
||||
install -m 644 systemd/rrpp-qdisc.service /etc/systemd/system/
|
||||
systemctl daemon-reload && systemctl enable --now rrpp-qdisc
|
||||
sysctl --system
|
||||
```
|
||||
|
||||
**为什么文件名是 `99-zz-`**:sysctl 按文件名字典序加载、**后者覆盖前者**。`99-zz-` 保证本文件排在最后,
|
||||
避免被其它 `99-*.conf` 覆盖。
|
||||
|
||||
**⚠ 最容易踩的坑:`/etc/sysctl.conf`**。`sysctl --system` 会在**所有 sysctl.d 之后**再加载 `/etc/sysctl.conf`。
|
||||
2026-09 实测:某节点(aq)存在 2022 年的一键优化版 `/etc/sysctl.conf`,把 `tcp_mtu_probing` 覆盖回 `0`,
|
||||
导致 sysctl.d 里的设置完全不生效(现象:`sysctl -n` 与文件内容不一致)。
|
||||
**处理**:该文件里凡是与本规范重叠的键,逐行注释掉(保留其非冲突的调优,如 conntrack、rps、swappiness),
|
||||
或直接把本规范内容追加到该文件末尾。排查方法:
|
||||
|
||||
```bash
|
||||
sysctl -p /etc/sysctl.d/99-zz-rrpp-network.conf # 显式加载后应看到期望值
|
||||
sysctl --system 2>/dev/null | grep -n tcp_mtu_probing | tail -5 # 看谁在最后覆盖
|
||||
```
|
||||
|
||||
**KVM / 完整 VM 可写(本规范设置项)**
|
||||
|
||||
| 项 | 值 | 作用 |
|
||||
|---|---|---|
|
||||
| `tcp_congestion_control` / `core.default_qdisc` | `bbr` / `fq` | BBR 需 fq 搭配,否则 bufferbloat |
|
||||
| `tcp_mtu_probing` | 1 | PMTUD 黑洞缓解(见 2.4) |
|
||||
| `tcp_fastopen` | 3 | 客户端+服务端 TFO,省一次 RTT |
|
||||
| `tcp_slow_start_after_idle` | 0 | 空闲后不回退慢启动(长连接代理有益) |
|
||||
| `tcp_tw_reuse` / `tcp_fin_timeout` | 2 / 15 | TIME-WAIT 复用与回收 |
|
||||
| `tcp_keepalive_time/intvl/probes` | 300 / 30 / 5 | 及时清理死连接(默认 7200 过长) |
|
||||
| `ip_local_port_range` | 10240 65000 | 出站端口范围,防高并发端口耗尽 |
|
||||
| `core.somaxconn` / `tcp_max_syn_backlog` | 32768 | accept / 半连接队列 |
|
||||
| `core.netdev_max_backlog` | 32768 | 网卡收包队列(默认 1000 易突发丢包) |
|
||||
| `core.rmem_max` / `wmem_max` | 33554432 | socket 缓冲上限(默认 208KB 偏小) |
|
||||
| `tcp_rmem` / `tcp_wmem` | 4K/128K/32M、4K/16K/32M | 自动调优范围 |
|
||||
|
||||
**LXC / OpenVZ 容器不可用(由宿主机控制,实测 2026-09)**
|
||||
|
||||
| 项 | 现象 |
|
||||
|---|---|
|
||||
| `core.rmem_max` / `core.wmem_max` | **不存在**或 `-r--r--r--` 只读 |
|
||||
| `core.default_qdisc` | **不存在**(`sysctl` 报 cannot stat) |
|
||||
| `core.netdev_max_backlog` | 缺失 |
|
||||
| 默认路由网卡根 qdisc | `noqueue`,**无法设置 fq** |
|
||||
| `/usr/lib/sysctl.d` 的 `kernel.pid_max`、`fs.protected_*` | `permission denied`——容器内属**正常现象**,与本文档无关 |
|
||||
|
||||
> 结论:容器节点只能调 **netns 级**项(拥塞算法、MTU 探测、TFO、TW 复用、keepalive、
|
||||
> somaxconn、syn backlog、本地端口范围);socket 缓冲上限与 qdisc 需服务商在**宿主机**设置。
|
||||
> 因此容器节点想进一步提升,应优先在**应用层/传输层**做(例如走 UoT 而不是直连 UDP)。
|
||||
|
||||
**qdisc 为什么必须单独用 systemd 单元**:`net.core.default_qdisc=fq` **只对新建网卡生效**;
|
||||
已存在的网卡即使重启也可能仍是 `pfifo_fast` / `fq_codel`(实测:aq 为 `pfifo_fast`、us 为 `fq_codel`)。
|
||||
`rrpp-qdisc.service` 在开机后显式执行 `tc qdisc replace dev <默认路由网卡> root fq` 补齐;
|
||||
脚本对容器(`noqueue`)会自动跳过。
|
||||
|
||||
**UDP 策略(2026-09-20 定稿)**:UDP 传输(Hysteria2 / TUIC)曾于当日全节点部署并端到端验证,
|
||||
但基于「**抗封优先**」的评估,**当晚已全部撤除**(详情与留档见 §6.4)。现行口径:
|
||||
|
||||
- **节点不部署 UDP 传输服务**;UDP 需求统一走 **UoT v2**(§5.5)——数据报封装进 TCP CONNECT 隧道,
|
||||
经 nginx magic 地址转本机 `127.0.0.1:15201` relay 解封装;
|
||||
- 抗封主路径 = **REALITY + Vision(TCP)**,不做 fallback 组(订阅仍为 `select`);
|
||||
- 节点上的 **UDP 系统调优项保留**(无害,且对 UoT / SS-UDP 有轻微收益)。
|
||||
|
||||
撤除理由(实测 + 推演):非标准 UDP 端口被封对 GFW **几乎零代价**(不像封 TCP 443 会误伤大量正常 HTTPS);
|
||||
UDP 在 CN 长期被 QoS/限速;且一旦 IP 因 UDP 特征被判为代理,会**连带打死同 IP 上的 TCP 服务**(REALITY/SS/nginx)。
|
||||
加密性本身没有问题(QUIC=TLS1.3,实测证书校验强制、错凭据被拒)——风险在**可封禁面**,不在密码学。
|
||||
|
||||
节点侧 UDP 相关系统调优(保留):
|
||||
|
||||
| 项 | 值 / 说明 |
|
||||
|---|---|
|
||||
| `net.ipv4.udp_rmem_min` / `udp_wmem_min` | `8192`(容器内可写,实测 LXC 同样生效) |
|
||||
| `net.core.rmem_max` / `wmem_max` | `33554432`(KVM 可写;**LXC 缺失/只读**) |
|
||||
| `rx-udp-gro-forwarding` / `rx-gro-list`(ethtool) | 由 `rrpp-qdisc.sh` 开机开启;**实测支持情况**:us/jp ✅ 两项;aq(内核 5.10)仅 `rx-gro-list`;**LXC 容器无 netdev 权限,无法设置** |
|
||||
| QUIC/UDP 吞吐 | 受宿主机 socket 缓冲上限约束——**LXC 节点上这是硬约束**,需服务商在宿主调 `rmem_max` |
|
||||
|
||||
**明确不采用的手段**:锐速/LotServer(闭源、绑定旧内核)、net-speeder(重复包、已废弃)、
|
||||
`tcp_tw_recycle`(4.12 起内核已移除,开启会破坏 NAT 后客户端)、`tcp_notsent_lowat` 激进值
|
||||
(可能压制高 BDP 吞吐)、`busy_poll`/`busy_read`(1 vCPU 机器上代价大于收益)、各类"一键优化脚本"
|
||||
(多为本文档子集 + 心理安慰,且常与 sysctl.d 冲突)。
|
||||
|
||||
**落地状态(2026-09-20)**
|
||||
|
||||
| 节点 | 虚拟化 | 落地情况 |
|
||||
|---|---|---|
|
||||
| hk2 | LXC(Debian 12) | LXC 模板;`bbr` + `mtu_probing=1` + `fastopen=3` + 队列/端口项;缓冲与 qdisc 受宿主限制 |
|
||||
| ru | LXC(Debian 13 cloud 内核) | 同上(`rmem_max`/`wmem_max` 只读) |
|
||||
| us | KVM(Debian 12) | KVM 模板 + fq(原 `fq_codel`) |
|
||||
| aq | KVM(Debian 11 cloud) | KVM 模板 + fq(原 `pfifo_fast`);并注释其 `/etc/sysctl.conf` 中 17 行冲突键(原 `tcp_mtu_probing=0`) |
|
||||
| jp | Azure VM(Ubuntu 6.17) | KVM 模板(经 `sudo` 落地;原有 `99-tune.conf` 保留) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 伪装站点页面
|
||||
@@ -328,6 +433,21 @@ magic 地址 → `127.0.0.1:15201`,其余 CONNECT 走 `$connect_addr` 默认
|
||||
4. 模块**拒绝 CONNECT 端口 0**(`port < 1` 返回 400)→ 客户端连 magic 地址时
|
||||
端口必须非 0(用 443 即可,relay 不读 CONNECT 端口,目标取自 UoT 帧头)。
|
||||
|
||||
**健壮性要求(2026-09 实测修复,别踩)**:
|
||||
- relay 必须用**未连接**的 UDP socket(`sendto`/`recvfrom`)。用连接式 socket 时,
|
||||
目标侧 ICMP(port unreachable 等)会变成 `ConnectionRefusedError` 在 send/recv 上抛出,
|
||||
若被兜底 `except` 接住并 `stop.set()`,**一条数据报出错就杀掉整条 UoT 隧道**
|
||||
(客户端表现为 "UoT TCP 流已关闭"、多条隧道同秒集体死亡)。
|
||||
- 数据报级错误(`BlockingIOError`/EAGAIN、`ENETUNREACH`、`EHOSTUNREACH`、`EMSGSIZE`)
|
||||
**只丢当前包**,EAGAIN 先等可写再重试;只有 TCP EOF、协议错误、空闲超时才关隧道。
|
||||
- 按地址族惰性建 socket,non-connect 模式可混用 IPv4/IPv6 目标;IPv6 `recvfrom`
|
||||
返回 4 元组需归一化。
|
||||
- connect 模式要**预建** socket(否则主循环首轮无可 select 对象,会立刻关连接)。
|
||||
|
||||
**节点出口差异(实测)**:RU 节点到境外 DNS(8.8.8.8:53)UDP 成功率仅约 4/10
|
||||
(俄 ISP 限制),表现为 UoT DNS 偶发超时——属该节点出口特性,非 relay 故障。
|
||||
此类节点的 UDP 业务建议换用当地可达目标,或走其它节点。
|
||||
|
||||
**客户端协议**:认证与 TCP CONNECT 相同(TLS + Basic Auth),然后
|
||||
`CONNECT sp.v2.udp-over-tcp.arpa:443 HTTP/1.1`,200 之后收发 UoT v2 帧:
|
||||
请求头 `| isConnect u8 | ATYP u8 | address | port u16be |`,
|
||||
@@ -373,6 +493,16 @@ install -m 755 sing-box-$SINGBOX_VER-linux-amd64/sing-box /usr/local/bin/sing-bo
|
||||
|
||||
完整配置见 [sing-box/config.json](sing-box/config.json)。要点:
|
||||
- **VLESS+REALITY**:每节点独立 UUID/密钥对/short_id,SNI 全节点统一,flow `xtls-rprx-vision`;REALITY handshake server 用 `<伪装SNI域名>:443`
|
||||
- **纯 IPv4 节点**(如 Azure 不给 IPv6 的 jp):direct 出站**必须**配 `domain_resolver`,
|
||||
否则双栈域名会解析出 AAAA 并拨向不可达的 IPv6(日志刷 `network is unreachable`,
|
||||
jp 实测每小时数十条)。1.12+ 写法(legacy `domain_strategy` 字段在 sing-box 1.13
|
||||
已直接 FATAL 拒绝,勿用):
|
||||
```json
|
||||
"dns": {"servers": [{"type": "local", "tag": "local-dns"}]},
|
||||
"outbounds": [{"type": "direct", "tag": "direct",
|
||||
"domain_resolver": {"server": "local-dns", "strategy": "ipv4_only"}}]
|
||||
```
|
||||
已验证:jp 需要(2026-10-01 已修);ru/hk2/us/aq 有 IPv6 出口或实测无此错误,不需要。
|
||||
- **Shadowsocks**:`2022-blake3-aes-256-gcm`(SIP022:会话子密钥 + 抗重放探测,显著优于老 AEAD `aes-256-gcm`)+ 每节点独立 PSK,独立端口(兼容不支持 REALITY 的老客户端)
|
||||
- **PSK 格式要求**:密码必须是 base64 编码的 32 字节密钥(`openssl rand -base64 32`),不是任意字符串;改错格式 sing-box 启动即报错
|
||||
- sing-box / Shadowrocket / mihomo 均支持 2022-blake3 系列
|
||||
@@ -412,6 +542,94 @@ EOF
|
||||
|
||||
---
|
||||
|
||||
### 6.4 Hysteria2 / TUIC(UDP 传输)— ⚠️ 已回滚,本节为留档
|
||||
|
||||
> **状态:已回滚(2026-09-20 当晚)。** 当日曾在五节点部署并逐组合端到端验证通过(见下表与「验证方法」),
|
||||
> 随后因「**绝对不可以被 GFW 封禁**」这一优先级,**全部撤除**——五节点 sing-box 已恢复为
|
||||
> `vless + shadowsocks` 原状,新增的 UDP 入站不再监听。
|
||||
>
|
||||
> 本节保留作**留档**:配置片段、各节点 NAT/NSG 差异、端到端验证方法(尤其"只看监听不足以证明外部可达"
|
||||
> 这个坑)在日后重新启用或换机时仍然有效。若要重新启用,照本节执行即可,**但请先读 §2.6 的撤除理由**。
|
||||
|
||||
TCP 系协议在跨境丢包链路上受拥塞控制限制(BBR 已缓解但仍有天花板);QUIC 系
|
||||
(**Hysteria2** 的 Brutal CC、**TUIC v5**)能显著改善高丢包下的吞吐与时延。
|
||||
两者都是 **sing-box 内建**,无需额外二进制;UDP 需求也可走 UoT(§5.5)。
|
||||
|
||||
```json
|
||||
// Hysteria2(sing-box inbound)
|
||||
{ "type": "hysteria2", "tag": "hy2-in-<端口>", "listen": "0.0.0.0", "listen_port": "<端口>",
|
||||
"up_mbps": 100, "down_mbps": 100,
|
||||
"obfs": { "type": "salamander", "password": "<obfs密码>" },
|
||||
"users": [ { "name": "<节点>-hy2", "password": "<hy2密码>" } ],
|
||||
"tls": { "enabled": true, "server_name": "<节点域名>",
|
||||
"certificate_path": "/etc/nginx/ssl/<节点域名>/fullchain.pem",
|
||||
"key_path": "/etc/nginx/ssl/<节点域名>/privkey.pem", "alpn": ["h3"] },
|
||||
"masquerade": "file:///var/www/html" }
|
||||
|
||||
// TUIC v5
|
||||
{ "type": "tuic", "tag": "tuic-in-<端口>", "listen": "0.0.0.0", "listen_port": "<端口>",
|
||||
"users": [ { "name": "<节点>-tuic", "uuid": "<UUID>", "password": "<密码>" } ],
|
||||
"congestion_control": "bbr",
|
||||
"tls": { /* 同上 */ } }
|
||||
```
|
||||
|
||||
**要点**
|
||||
|
||||
- **带宽声明决定 Brutal**:`up_mbps`/`down_mbps` 不写时,服务端会命令客户端改用 BBR;
|
||||
写了才允许 Hysteria CC(Brutal)。默认 100 Mbps,计量流量的节点给 50。
|
||||
Brutal **不遵守拥塞控制**,设错会占满链路甚至被服务商判定异常流量——务必按实际带宽设。
|
||||
- `obfs: salamander` 给 QUIC 加一层混淆(对抗 QUIC 特征识别);`masquerade` 指向伪装页,
|
||||
未认证访问返回与 nginx 一致的页面。
|
||||
- TUIC 保持 `zero_rtt_handshake` 关闭(默认,0-RTT 可被重放)。
|
||||
- 凭据**每节点独立**,与 VLESS/SS/CONNECT 同策略。
|
||||
|
||||
**各节点端口与部署差异(2026-09-20 实测,务必照抄结论)**
|
||||
|
||||
| 节点 | 虚拟化 | Hysteria2 (UDP) | TUIC (UDP) | 差异 / 坑 |
|
||||
|---|---|---|---|---|
|
||||
| hk2 | LXC(NAT) | 4305 | 4306 | NAT 段 4301-4320 UDP 1:1(探针 + 外部回扫实测) |
|
||||
| us | KVM | 1208 | 1209 | 公网直连,UDP 任意端口可用 |
|
||||
| ru | LXC(NAT) | 23333 | 23334 | **外部只有宿主已映射的 UDP 端口可达**:容器内 bind 任意端口都"正常",但 23336+ 实测直接 `connection refused`(ICMP port unreachable);最终复用 **23333/23334**——与 nginx/vless 占用的 **TCP 同号**,UDP 与 TCP 互不冲突 |
|
||||
| aq | KVM(IPv6-only) | 8444 | 8445 | 节点为 IPv6 出口,`listen` 用 `"::"`;客户端须走 IPv6 |
|
||||
| jp | Azure VM | 未部署 | 8445 | **Azure NSG 只放行 UDP 8445**(443/80/8080/8088/18080/8443/9444 的 UDP 面实测均不可达)→ 单节点只能承载一个 UDP 协议;要加 Hysteria2 须先在 NSG 放行第二个 UDP 端口 |
|
||||
|
||||
**客户端链接格式(手工导入用)**
|
||||
|
||||
```
|
||||
hysteria2://<password>@<域名>:<端口>/?sni=<域名>&alpn=h3&obfs=salamander&obfs-password=<obfs密码>#备注
|
||||
tuic://<uuid>:<password>@<域名>:<端口>/?congestion_control=bbr&alpn=h3&sni=<域名>#备注
|
||||
```
|
||||
|
||||
**接入现状(2026-09-20,务必先读)**
|
||||
|
||||
| 层 | 状态 |
|
||||
|---|---|
|
||||
| 服务端 | ⛔ **已撤除**(原:五节点部署并端到端验证)——2026-09-20 当晚按抗封优先回滚 |
|
||||
| **订阅下发** | ❌ 从未下发(`app.py` 无 hysteria2/tuic 生成逻辑,`nodes.json` 无对应字段;订阅里只有 http / vless / ss),且已决定不做 |
|
||||
| **代理组** | ❌ 订阅只生成一个 `RRPP`(`type: select`,手动选节点),**没有 fallback / url-test**,因此**不存在"UDP 挂了自动回落 TCP"** |
|
||||
| lite 客户端(Windows) | 仅 HTTP CONNECT + UoT v2(UDP 走 magic 地址),**不使用 UDP 入站**,且节点为手动切换、无自动故障转移 |
|
||||
| 结论 | UDP 通道目前**只能手工导入**上面的链接使用;抗封主路径仍是 **REALITY+Vision(TCP)**,UDP 属可选加速,未接入客户端链路 |
|
||||
|
||||
> 若日后要接入订阅,需要三步:① `nodes.json` 每节点补 `methods.hysteria2` / `methods.tuic`;
|
||||
> ② `app.py` 增加 mihomo `type: hysteria2` / `type: tuic` 生成与分享链接;
|
||||
> ③ 代理组改为**每节点一个 `fallback` 组、REALITY 排第一**(否则"UDP 挂了回落 TCP"不成立)。
|
||||
> 注意 mihomo 的 fallback 依赖健康检查周期(默认 300s),切换不是瞬时的。
|
||||
|
||||
**验证方法(端到端,不能只看监听)**
|
||||
|
||||
```bash
|
||||
# 在任意机器用 sing-box 作客户端:socks 入站 + 上述 outbound,然后
|
||||
curl -s -m 20 -x socks5h://127.0.0.1:11080 -o /dev/null -w '%{http_code}\n' http://example.com/ # 期望 200
|
||||
```
|
||||
|
||||
`ss -lunp` 看到端口在听**不足以**证明外部可达:ru(NAT 未映射)与 jp(NSG 未放行)
|
||||
都是"监听正常但外部不可达"的真实案例。
|
||||
|
||||
⚠️ 代价与限制:UDP 在部分运营商会被 QoS/封禁;Brutal 对同链路其他流量不友好;
|
||||
计量流量的节点(如 jp,15GB/月)注意消耗;LXC 容器内 socket 缓冲上限受宿主机约束。
|
||||
|
||||
---
|
||||
|
||||
## 7. mtg(MTProto 代理)
|
||||
|
||||
Telegram 内置代理,Fake TLS 模式抗封锁。用 [mtg](https://github.com/9seconds/mtg)(Go 单二进制):
|
||||
@@ -470,6 +688,7 @@ mtg simple-run --prefer-ip=prefer-ipv4 0.0.0.0:<mtproto端口> <上面生成的s
|
||||
| sing-box | [systemd/sing-box.service](systemd/sing-box.service) |
|
||||
| mtg | [systemd/mtg.service](systemd/mtg.service) |
|
||||
| uot-relay | [uot-relay/uot-relay.service](uot-relay/uot-relay.service) |
|
||||
| rrpp-qdisc(fq 队列,见 2.6) | [systemd/rrpp-qdisc.service](systemd/rrpp-qdisc.service) |
|
||||
|
||||
```bash
|
||||
cp systemd/*.service /etc/systemd/system/
|
||||
@@ -498,6 +717,13 @@ systemctl start uot-relay # 5. UoT relay(须在 nginx 前可用
|
||||
sysctl net.ipv4.tcp_congestion_control # => bbr
|
||||
sysctl net.ipv4.tcp_mtu_probing # => 1(PMTUD 黑洞缓解,见 2.4)
|
||||
|
||||
# 系统级网络优化(见 2.6):确认 sysctl.d 的值真的生效(注意 /etc/sysctl.conf 覆盖坑)
|
||||
sysctl -n net.core.somaxconn net.ipv4.tcp_max_syn_backlog net.ipv4.tcp_fastopen \
|
||||
net.ipv4.tcp_slow_start_after_idle net.ipv4.ip_local_port_range
|
||||
# KVM/完整 VM 追加:net.core.netdev_max_backlog / rmem_max / wmem_max
|
||||
tc qdisc show | grep -v noqueue # 期望默认路由网卡为 fq(容器内为 noqueue,属正常)
|
||||
systemctl is-active rrpp-qdisc # 期望 active(容器内该单元会自行跳过)
|
||||
|
||||
# fail2ban
|
||||
fail2ban-client status sshd # => jail 存在(见 2.5)
|
||||
|
||||
@@ -515,6 +741,12 @@ print('resp:',s.recv(8).hex())" # 期望首字节 15 (TLS alert)
|
||||
# UoT relay(127.0.0.1:15201 监听 + CONNECT magic 地址)
|
||||
ss -tlnp | grep 15201 # => uot_relay.py 监听 127.0.0.1:15201
|
||||
|
||||
# UDP 传输(Hysteria2/TUIC)——已于 2026-09-20 撤除;以下仅当重新启用时才适用
|
||||
# 必须端到端验证,不能只看监听
|
||||
ss -lunp | grep -E ':<hy2端口>|:<tuic端口>' # 仅确认服务在听
|
||||
# 再用 sing-box 客户端 + curl 经由 socks 实测(见 6.4「验证方法」)
|
||||
ethtool -k <网卡> | grep -E 'rx-udp-gro-forwarding|rx-gro-list' # KVM 期望 on;LXC 容器不可设
|
||||
|
||||
# nginx CONNECT(带 Basic Auth,B64 = base64('<代理用户名>:<代理密码>'))
|
||||
B64=$(printf '<代理用户名>:<代理密码>' | base64 -w0)
|
||||
curl -sk --proxy-insecure -x https://<节点域名>:<nginx端口> \
|
||||
@@ -532,6 +764,9 @@ curl -sk https://<节点域名>:<nginx端口>/ -o /dev/null -w '%{http_code}\n'
|
||||
```
|
||||
server_config/
|
||||
├── README.md ← 本文档
|
||||
├── sysctl/
|
||||
│ ├── 99-zz-rrpp-network.kvm.conf ← 系统级网络优化(KVM/完整 VM,见 §2.6)
|
||||
│ └── 99-zz-rrpp-network.lxc.conf ← 系统级网络优化(LXC/OpenVZ 容器,见 §2.6)
|
||||
├── nginx/
|
||||
│ ├── nginx.conf ← 完整 nginx 配置(占位符版)
|
||||
│ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改)
|
||||
@@ -544,13 +779,81 @@ server_config/
|
||||
├── systemd/
|
||||
│ ├── nginx.service
|
||||
│ ├── sing-box.service
|
||||
│ └── mtg.service
|
||||
│ ├── mtg.service
|
||||
│ └── rrpp-qdisc.service ← 开机把默认网卡 qdisc 设为 fq(见 §2.6)
|
||||
├── scripts/
|
||||
│ └── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护
|
||||
│ ├── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护
|
||||
│ └── rrpp-qdisc.sh ← fq 队列设置脚本(rrpp-qdisc.service 调用)
|
||||
├── cron/
|
||||
│ └── sync-nginx-local-ips ← cron 定义(每 5 分钟)
|
||||
├── rrpp-subscription/ ← RRPP 订阅后端(bj,见 §12)
|
||||
│ ├── API.md ← 订阅端点 / method / 分层 / 规则集文档
|
||||
│ └── gen_rulesets.py ← PAC → 客户端规则集转换脚本
|
||||
└── fake-site/
|
||||
└── index.html ← 伪装站点页面(所有节点共用同一份)
|
||||
```
|
||||
|
||||
> 旧的 `traffic-monitor/`、`pac/`、`conf.d/*.conf` 已下线或迁移,不再维护,已从本目录移除。
|
||||
|
||||
---
|
||||
|
||||
## 12. RRPP 订阅后端(PAC → 规则集下发)
|
||||
|
||||
订阅服务运行在 **bj**(`C:/BtSoft/nginx/others/backEnd/api-rrrosmontis-icu/RRPP/`,nssm 服务 `RRPPSubBackend`,监听 `127.0.0.1:5010`,由 nginx `api.rrrosmontis.icu` 反代)。
|
||||
完整端点说明见 `rrpp-subscription/API.md`(bj 上同名文件为主副本,两处内容保持一致)。
|
||||
|
||||
### 12.1 PAC 为什么不能直接下发
|
||||
|
||||
PAC 是浏览器/系统用的 JS 自动配置,**mihomo/Clash 与 Shadowrocket 都读不了**。两者唯一共同能吃的格式是
|
||||
「每行自带规则类型的规则行文本」(Clash 的 `behavior: classical`):
|
||||
|
||||
- mihomo → `rule-providers`(`behavior: classical` + `format: text`)
|
||||
- Shadowrocket → `RULE-SET,<url>,<策略>`(官方手册:规则集内容需包含规则类型)
|
||||
|
||||
因此由后端 `gen_rulesets.py` 把 PAC 数据转成三个 `.list`,两种客户端共用同一份文件。
|
||||
|
||||
### 12.2 规则集产物
|
||||
|
||||
| 规则集 | 文件(`https://cdn.rrrosmontis.icu/RRPP/PAC/`) | 规则数 | 策略 |
|
||||
|---|---|---|---|
|
||||
| `rrpp-proxy` | `rrpp-proxy.list` | 3943(`DOMAIN` 539 + `DOMAIN-SUFFIX` 3404) | `RRPP` / `PROXY` |
|
||||
| `rrpp-direct` | `rrpp-direct.list` | 16(`DOMAIN-SUFFIX`) | `DIRECT` |
|
||||
| `rrpp-cn-ip` | `rrpp-cn-ip.list` | 7456(`IP-CIDR`) | `DIRECT` |
|
||||
|
||||
顺序(与 PAC 语义一致):强制代理 → 直连种子 → 中国 IP 直连 → `MATCH` 全代理。
|
||||
|
||||
### 12.3 维护:PAC 更新后重新生成
|
||||
|
||||
```bash
|
||||
# bj 上执行(Windows:C:/BtSoft/nginx/others/htmlResources/cdn/RRPP/PAC/ 为生成目录,同时即 CDN 根)
|
||||
python gen_rulesets.py # 幂等:内容无变化则不写盘;原子替换
|
||||
python gen_rulesets.py --check # 只预览差异,不写盘
|
||||
```
|
||||
|
||||
产物文件名与 URL 固定,**订阅端与客户端均无需改动**:mihomo 按 `interval: 86400` 自动更新,
|
||||
Shadowrocket 在「配置 → 规则集URL」中自动更新。
|
||||
|
||||
### 12.4 客户端接入
|
||||
|
||||
```bash
|
||||
# mihomo / Clash Verge:规则走 rule-providers(YAML 约 2.4KB)
|
||||
...&method=clash
|
||||
# 不认 rule-providers,或要「导入一次到位」:规则内联(约 440KB)
|
||||
...&method=clash&rules=inline
|
||||
# Shadowrocket:只下发规则([General] + 远程 RULE-SET + FINAL,不含节点)
|
||||
...&method=shadowrocket
|
||||
# 兜底:全流量走代理(旧行为)
|
||||
...&method=clash&rules=global
|
||||
```
|
||||
|
||||
> `method=shadowrocket` 的 `.conf` 不写 `[Proxy]` 段——Shadowrocket 的 REALITY 参数写法官方未文档化,不猜测;
|
||||
> 策略 `PROXY` 指向首页当前选中的节点,节点用 `clash`/`v2ray` 订阅导入。
|
||||
> 想在小火箭里「一次导入节点+规则」,用 `method=clash&rules=inline`。
|
||||
|
||||
### 12.5 验证口径
|
||||
|
||||
- 生成:11415 条规则(`regexp:` 跳过 7、非法 1)
|
||||
- CDN:三个 URL HTTP 200,md5 与本地一致(`806808b8` / `8f89397e` / `3e75093d`)
|
||||
- mihomo v1.19.31 真实内核:`-t` 通过;运行时 `GET /providers/rules` 报 `3943 / 16 / 7456`(`behavior=Classical`),
|
||||
落地文件字节数与生成一致;`rules=inline` 亦通过 `-t`
|
||||
- Shadowrocket 为 iOS 应用,未做实机验证(依官方手册 `RULE-SET` 与 Clash YAML 兼容说明实现)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# RRPP 订阅服务 · SSO 认证接入文档
|
||||
|
||||
架构:`客户端 → nginx(api.rrrosmontis.icu) → RRPP 后端(127.0.0.1:5010) → SSO(127.0.0.1:4550) → nodes.json(国家分层)`
|
||||
|
||||
## 1. 客户端公开端点
|
||||
|
||||
### 统一订阅端点
|
||||
```
|
||||
GET https://api.rrrosmontis.icu/v1/rrpp/subscription?method=<类型>&user=<b64url邮箱>&password=<b64url密码>[&prefer=<协议>][&rules=<模式>]
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `method` | 是 | `clash` / `v2ray` / `shadowrocket` / `rrpp_client` |
|
||||
| `user` | 是 | URL-safe base64 编码的邮箱 |
|
||||
| `password` | 是 | URL-safe base64 编码的密码 |
|
||||
| `prefer` | 否 | `vless` / `ss` / `http`,指定优先协议;缺省 `vless`,无则回退 vless→ss→http |
|
||||
| `rules` | 否 | 仅 `method=clash`:`provider`(默认) / `inline` / `global`,见 §7 |
|
||||
| `token` | 否 | SSO 会话 token(免密,代登录态);与 `user`/`password` 二选一 |
|
||||
|
||||
### 旧端点(兼容)
|
||||
```
|
||||
GET /v1/rrpp/nodes_update?user=<b64>&password=<b64>
|
||||
```
|
||||
等价于 `method=rrpp_client`。
|
||||
|
||||
## 2. method 说明
|
||||
|
||||
| method | Content-Type | 内容 | 适用客户端 |
|
||||
|---|---|---|---|
|
||||
| `clash` | `text/yaml` | Clash YAML:`proxies` + `proxy-groups(RRPP)` + `rule-providers` + `rules`(备注 `R.R.P.P. - 国家代码`) | mihomo / Clash Verge / ClashX Meta |
|
||||
| `v2ray` | `text/plain` | base64 节点串(`vless://` / `ss://` / `http://`) | v2rayN / Shadowrocket / Quantumult |
|
||||
| `shadowrocket` | `text/plain` | Shadowrocket `.conf`:`[General]` + 远程 `RULE-SET` + `FINAL`(**不含节点**) | Shadowrocket |
|
||||
| `rrpp_client` | `application/json` | 旧格式节点 JSON(仅 http,remark = `服务商 流量`) | RRPP 自建客户端 |
|
||||
|
||||
## 3. 返回码
|
||||
|
||||
| 码 | 场景 |
|
||||
|---|---|
|
||||
| 200 | 成功 |
|
||||
| 401 | 凭据缺失/SSO 认证失败 |
|
||||
| 403 | tier=0(RRPP 未开通) |
|
||||
| 404 | method 未知,或 `rules` 取值非法 |
|
||||
| 502 | SSO 不可达 |
|
||||
| 503 | nodes.json 缺失/损坏,或该 tier 无可用节点 |
|
||||
|
||||
## 4. nodes.json 格式(国家分层)
|
||||
|
||||
`C:/BtSoft/nginx/others/backEnd/api-rrrosmontis-icu/RRPP/nodes.json`,顶层为国家数组:
|
||||
|
||||
```jsonc
|
||||
[
|
||||
{
|
||||
"country": "JP", // 国家代码
|
||||
"available_level": [1, 2, 3], // 可见等级
|
||||
"provider": "Azure", // 服务商
|
||||
"traffic": "15G", // 流量限额
|
||||
"domain": "iam.aeolianchi.me", // 域名
|
||||
"ipv6_only": false, // 可选,true 时备注加 (IPv6)
|
||||
"methods": { // 可用协议
|
||||
"http": { "port": ..., "username": ..., "password": ... },
|
||||
"vless": { "port": ..., "uuid": ..., "flow": ..., "sni": ..., "public_key": ..., "short_id": ... },
|
||||
"ss": { "port": ..., "method": ..., "password": ... }
|
||||
}
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
## 5. 协议选择逻辑
|
||||
|
||||
- `prefer` 指定则优先用该协议
|
||||
- 缺省默认 `vless`(最好用)
|
||||
- 该国家无 `prefer` 协议时按 `vless → ss → http` 回退
|
||||
- `rrpp_client` 固定只用 `http`(兼容性),备注 = `provider + traffic`(IPv6 加 `(IPv6)`)
|
||||
|
||||
## 6. SSO 认证
|
||||
|
||||
- 后端调 SSO `POST http://127.0.0.1:4550/v1/sso/service/rrpp/nodes`(带 `X-SSO-Service-Token` + `X-Real-IP`)
|
||||
- SSO 返回 `{tier: 0|1|2|3}`,0=未开通
|
||||
- 服务密钥读 `sso-rrrosmontis-icu/secrets.json` 的 `service_tokens.rrpp`
|
||||
|
||||
## 7. 分流规则(PAC → 规则集,随订阅下发)
|
||||
|
||||
### 7.1 为什么不是直接下发 PAC
|
||||
|
||||
PAC 是给浏览器/操作系统用的 JS 自动配置;**mihomo/Clash 与 Shadowrocket 都不能读 PAC**。
|
||||
两者唯一共同能吃的格式是「每行自带规则类型的规则行文本」(Clash 术语 `behavior: classical`):
|
||||
|
||||
- mihomo:`rule-providers`(`behavior: classical` + `format: text`)
|
||||
- Shadowrocket:`RULE-SET,<url>,<策略>`(官方手册要求「规则集的内容需包含规则类型」)
|
||||
|
||||
因此把 PAC 数据**在后端一次性转换成三个规则集文件**,两种客户端共用同一份文件。
|
||||
|
||||
### 7.2 规则集文件(CDN 公开)
|
||||
|
||||
生成目录即 CDN 根下的 `RRPP/PAC/`,公开可访问、无鉴权(规则集本身不含节点信息):
|
||||
|
||||
| 规则集 | 文件 | 规则数 | 组成 | 策略 |
|
||||
|---|---|---|---|---|
|
||||
| `rrpp-proxy` | `rrpp-proxy.list` | 3943 | 539 `DOMAIN` + 3404 `DOMAIN-SUFFIX`(强制代理域名) | `RRPP` / `PROXY` |
|
||||
| `rrpp-direct` | `rrpp-direct.list` | 16 | 16 `DOMAIN-SUFFIX`(保守直连种子) | `DIRECT` |
|
||||
| `rrpp-cn-ip` | `rrpp-cn-ip.list` | 7456 | 7456 `IP-CIDR`(中国 IP 段) | `DIRECT` |
|
||||
|
||||
URL:`https://cdn.rrrosmontis.icu/RRPP/PAC/<文件名>`
|
||||
体积:120366 / 386 / 177443 字节,合计 11415 条规则。
|
||||
|
||||
### 7.3 规则顺序(与 PAC 语义一致)
|
||||
|
||||
```
|
||||
RULE-SET,rrpp-proxy → RRPP # 强制代理优先(PAC 中强制代理规则优先级更高)
|
||||
RULE-SET,rrpp-direct → DIRECT # 直连种子
|
||||
RULE-SET,rrpp-cn-ip → DIRECT # 中国 IP 直连
|
||||
MATCH → RRPP # 其余全走代理
|
||||
```
|
||||
|
||||
### 7.4 维护流程(PAC 更新后)
|
||||
|
||||
转换脚本 `gen_rulesets.py`(与本文件同目录,另在仓库 `server_config/rrpp-subscription/` 存有副本):
|
||||
|
||||
```bash
|
||||
python gen_rulesets.py # 读 PAC 目录 → 原子覆写三个 .list(幂等,内容不变则不写)
|
||||
python gen_rulesets.py --check # 只检查将有哪些变化,不写盘
|
||||
python gen_rulesets.py --pac-dir <目录> --out-dir <目录> --quiet
|
||||
```
|
||||
|
||||
- 输入:`pac_domains.json` / `pac_direct_domains.json` / `pac_ip_cn.json`
|
||||
- 转换规则:`full:x`→`DOMAIN,x`;`keyword:x`→`DOMAIN-KEYWORD,x`;`regexp:`/`include:`→跳过并计数;行内 `#` 注释剥离;`*.x`/普通域名→`DOMAIN-SUFFIX,x`
|
||||
- 输出排序去重、LF 换行、无注释,写盘用临时文件 + `os.replace` 原子替换
|
||||
- **订阅端无需任何改动**:文件名与 URL 固定,客户端按 `interval` 自动拉新规则
|
||||
|
||||
### 7.5 客户端接入
|
||||
|
||||
| 场景 | 用法 |
|
||||
|---|---|
|
||||
| mihomo / Clash Verge(推荐) | `&method=clash` → 规则走 `rule-providers`,YAML 约 2.4KB,规则每 24h 自动更新 |
|
||||
| 客户端不认 `rule-providers`,或想「导入一次到位」 | `&method=clash&rules=inline` → 规则内联进 YAML(约 440KB),导入即含节点+全部规则 |
|
||||
| Shadowrocket(只想要规则,节点另行导入) | `&method=shadowrocket` → `.conf`,规则走远程 `RULE-SET`(在「配置 → 规则集URL」中可见并自动更新) |
|
||||
| Shadowrocket(想一次导入节点+规则) | 用 `&method=clash&rules=inline`(小火箭兼容 Clash YAML,含节点信息的标准 Clash 链接可同时导入配置与节点) |
|
||||
| 兜底 / 排障 | `&rules=global` → 只有 `MATCH,RRPP`,全流量走代理(等价旧行为) |
|
||||
|
||||
> `method=shadowrocket` 的 `.conf` **不写 `[Proxy]` 段**:Shadowrocket 的 REALITY 参数写法官方未文档化,不猜测;
|
||||
> 该配置的策略 `PROXY` 指向首页当前选中的节点,节点请用 `clash`/`v2ray` 订阅导入。
|
||||
|
||||
### 7.6 验证记录(2026-09-19)
|
||||
|
||||
- 生成:11415 条规则;`regexp:` 跳过 7 条、非法 1 条
|
||||
- CDN:三个 URL 均 HTTP 200,`md5` 与本地生成一致(`806808b8` / `8f89397e` / `3e75093d`)
|
||||
- **mihomo v1.19.31 真实内核运行验证**:`-t` 校验通过;运行时 `GET /providers/rules` 显示
|
||||
`rrpp-proxy=3943`、`rrpp-direct=16`、`rrpp-cn-ip=7456`(`behavior=Classical`),落地文件字节数与生成完全一致;
|
||||
`&rules=inline` 配置亦通过 `-t` 校验
|
||||
- Shadowrocket 为 iOS 应用,**未做实机验证**,仅依据官方手册的 `RULE-SET` / Clash YAML 兼容说明实现
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
RRPP PAC → 规则集转换器
|
||||
|
||||
把 PAC 数据文件(JSON)转换成 **mihomo(Clash) 与 Shadowrocket 通用**的
|
||||
"classical 规则行"文本规则集——每行都含规则类型:
|
||||
|
||||
pac_direct_domains.json -> rrpp-direct.list (DOMAIN-SUFFIX,<域名>)
|
||||
pac_domains.json -> rrpp-proxy.list (DOMAIN-SUFFIX,<域名>)
|
||||
pac_ip_cn.json -> rrpp-cn-ip.list (IP-CIDR,<网段>)
|
||||
|
||||
为什么用这个格式(两端通吃):
|
||||
* mihomo : rule-providers { behavior: classical, format: text }
|
||||
* Shadowrocket: RULE-SET,<url>,<策略>
|
||||
(官方要求"规则集的内容需包含规则类型")
|
||||
|
||||
三种规则集由客户端按各自策略引用:
|
||||
rrpp-proxy.list -> 代理(强制代理域名)
|
||||
rrpp-direct.list -> 直连(保守直连种子)
|
||||
rrpp-cn-ip.list -> 直连(中国 IP 段)
|
||||
|
||||
用法
|
||||
----
|
||||
python gen_rulesets.py # 就地生成(内容有变化才写盘)
|
||||
python gen_rulesets.py --check # 只检查/报告,不写盘
|
||||
python gen_rulesets.py --pac-dir DIR --out-dir DIR
|
||||
python gen_rulesets.py --quiet
|
||||
|
||||
维护
|
||||
----
|
||||
PAC 更新后重新执行本脚本即可。脚本是**幂等**的:内容与现有文件一致时不会重写;
|
||||
写入采用 临时文件 + 原子替换,失败不会损坏已有文件。输出不含注释,只有规则行。
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ---------- 默认路径(bj 生产环境) ----------
|
||||
DEFAULT_PAC_DIR = r'C:/BtSoft/nginx/others/htmlResources/cdn/RRPP/PAC'
|
||||
DEFAULT_OUT_DIR = DEFAULT_PAC_DIR # 与 PAC 同目录,由 CDN 直接对外提供
|
||||
|
||||
# 映射:PAC 文件 -> (输出文件, JSON 中的列表字段, 类型)
|
||||
# 类型 domains = 域名表(含 v2fly 前缀:full:/keyword:/regexp:/行内注释)
|
||||
# 类型 cidrs = IP 网段表
|
||||
RULESETS = [
|
||||
('pac_domains.json', 'rrpp-proxy.list', 'domains'),
|
||||
('pac_direct_domains.json', 'rrpp-direct.list', 'domains'),
|
||||
('pac_ip_cn.json', 'rrpp-cn-ip.list', 'cidrs'),
|
||||
]
|
||||
|
||||
|
||||
def log(msg, quiet=False):
|
||||
if not quiet:
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def read_json(path):
|
||||
"""读取 JSON;容忍 BOM。"""
|
||||
with open(path, 'r', encoding='utf-8-sig') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _valid_domain(s):
|
||||
if not s or len(s) > 253 or '.' not in s:
|
||||
return False
|
||||
if any(c in s for c in ' /\\:@?#[]'):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def convert_domain_entry(v):
|
||||
"""把一条域名表条目转成规则行。
|
||||
|
||||
支持 v2fly geosite 前缀与行内注释(PAC 数据实测存在):
|
||||
full:<域名> -> DOMAIN,<域名> (精确匹配)
|
||||
keyword:<关键词> -> DOMAIN-KEYWORD,<关键词>
|
||||
regexp:<正则> -> 跳过(两端都无法表达域名正则)
|
||||
<域名> # 注释 -> DOMAIN-SUFFIX,<域名>
|
||||
返回 (规则行 或 None, 跳过原因 或 None)。
|
||||
"""
|
||||
s = str(v or '')
|
||||
if '#' in s: # 行内注释(如 "apple.xn--czr694b # apple.商标")
|
||||
s = s.split('#', 1)[0]
|
||||
s = s.strip().lower()
|
||||
if not s:
|
||||
return None, 'empty'
|
||||
|
||||
if s.startswith('regexp:'):
|
||||
return None, 'regexp'
|
||||
if s.startswith('include:'):
|
||||
return None, 'include'
|
||||
|
||||
if s.startswith('full:'):
|
||||
dom = s[5:].strip().lstrip('*.').rstrip('.')
|
||||
return (f'DOMAIN,{dom}', None) if _valid_domain(dom) else (None, 'invalid')
|
||||
|
||||
if s.startswith('keyword:'):
|
||||
kw = s[8:].strip()
|
||||
return (f'DOMAIN-KEYWORD,{kw}', None) if kw else (None, 'invalid')
|
||||
|
||||
if s.startswith('domain:'):
|
||||
s = s[7:].strip()
|
||||
|
||||
dom = s.lstrip('*.').rstrip('.') # 通配前缀统一按后缀匹配
|
||||
return (f'DOMAIN-SUFFIX,{dom}', None) if _valid_domain(dom) else (None, 'invalid')
|
||||
|
||||
|
||||
def norm_cidr(v):
|
||||
"""CIDR 规范化/校验:返回规范化网段,非法返回 None。"""
|
||||
s = str(v or '').strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
net = ipaddress.ip_network(s, strict=False)
|
||||
except ValueError:
|
||||
return None
|
||||
return str(net.network_address) + '/' + str(net.prefixlen)
|
||||
|
||||
|
||||
def build_lines(kind, data, quiet=False):
|
||||
"""由 PAC JSON 生成规则行(排序去重),返回 (lines, stats)。"""
|
||||
field = 'domains' if kind == 'domains' else 'cidrs'
|
||||
raw = data.get(field) if isinstance(data, dict) else None
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError(f'字段 {field} 不是列表')
|
||||
seen, stats, out = set(), {}, []
|
||||
for item in raw:
|
||||
if kind == 'domains':
|
||||
line, why = convert_domain_entry(item)
|
||||
else:
|
||||
c = norm_cidr(item)
|
||||
line, why = (f'IP-CIDR,{c}', None) if c else (None, 'invalid')
|
||||
if line is None:
|
||||
stats[why] = stats.get(why, 0) + 1
|
||||
continue
|
||||
if line not in seen:
|
||||
seen.add(line)
|
||||
out.append(line)
|
||||
out.sort()
|
||||
if stats:
|
||||
detail = ' '.join(f'{k}={v}' for k, v in sorted(stats.items()))
|
||||
log(f' ! 跳过条目: {detail}', quiet)
|
||||
return out, stats
|
||||
|
||||
|
||||
|
||||
def write_if_changed(path, text, check_only=False):
|
||||
"""内容有变化才原子写入。返回 (changed, md5)。"""
|
||||
data = text.encode('utf-8')
|
||||
md5 = hashlib.md5(data).hexdigest()
|
||||
old = None
|
||||
if os.path.exists(path):
|
||||
with open(path, 'rb') as f:
|
||||
old = f.read()
|
||||
if old == data:
|
||||
return False, md5
|
||||
if check_only:
|
||||
return True, md5
|
||||
tmp = path + '.tmp'
|
||||
with open(tmp, 'wb') as f: # 二进制写:确保 LF、无 BOM
|
||||
f.write(data)
|
||||
os.replace(tmp, path)
|
||||
return True, md5
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='RRPP PAC -> 规则集转换器')
|
||||
ap.add_argument('--pac-dir', default=DEFAULT_PAC_DIR, help='PAC JSON 所在目录')
|
||||
ap.add_argument('--out-dir', default=DEFAULT_OUT_DIR, help='规则集输出目录')
|
||||
ap.add_argument('--check', action='store_true', help='只检查,不写盘')
|
||||
ap.add_argument('--quiet', action='store_true')
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isdir(args.pac_dir):
|
||||
print(f'错误:PAC 目录不存在: {args.pac_dir}', file=sys.stderr)
|
||||
return 2
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
|
||||
total, changed_cnt, failed = 0, 0, 0
|
||||
log(f'PAC 目录 : {args.pac_dir}')
|
||||
log(f'输出目录 : {args.out_dir}{" (--check 模式,不写盘)" if args.check else ""}')
|
||||
log('')
|
||||
|
||||
for src_name, out_name, kind in RULESETS:
|
||||
src_path = os.path.join(args.pac_dir, src_name)
|
||||
out_path = os.path.join(args.out_dir, out_name)
|
||||
if not os.path.exists(src_path):
|
||||
print(f' [跳过] {src_name} 不存在', file=sys.stderr)
|
||||
failed += 1
|
||||
continue
|
||||
try:
|
||||
data = read_json(src_path)
|
||||
lines, _stats = build_lines(kind, data, args.quiet)
|
||||
text = '\n'.join(lines) + '\n'
|
||||
changed, md5 = write_if_changed(out_path, text, args.check)
|
||||
size = len(text.encode('utf-8'))
|
||||
ver = data.get('version', '-') if isinstance(data, dict) else '-'
|
||||
state = ('已更新' if changed else '无变化') if not args.check else ('需更新' if changed else '无变化')
|
||||
log(f' {src_name:26s} -> {out_name:18s} {len(lines):5d} 行 {size/1024:7.1f} KB '
|
||||
f'ver={ver:12s} md5={md5[:8]} [{state}]')
|
||||
total += len(lines)
|
||||
changed_cnt += 1 if changed else 0
|
||||
except Exception as e:
|
||||
print(f' [失败] {src_name}: {type(e).__name__}: {e}', file=sys.stderr)
|
||||
failed += 1
|
||||
|
||||
log('')
|
||||
log(f'合计 {total} 条规则,{changed_cnt} 个文件{"需更新" if args.check else "已更新"},{failed} 个失败')
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# RRPP 网络接口调优脚本(开机执行)
|
||||
# 1) 把默认路由网卡的根 qdisc 设为 fq —— BBR 的最佳搭档
|
||||
# (net.core.default_qdisc=fq 只对"新创建"网卡生效,已存在网卡需显式 replace)
|
||||
# 2) 开启 UDP GRO 转发 —— QUIC 系协议(Hysteria2 / TUIC)转发性能
|
||||
# 容器(LXC/OpenVZ)内网卡为 noqueue、且无 netdev 权限,本脚本自动跳过。
|
||||
# ============================================================
|
||||
set -uo pipefail
|
||||
IFACE=$(ip -4 route show default | awk '{print $5; exit}')
|
||||
[ -n "${IFACE:-}" ] || { echo "未找到默认路由网卡,跳过"; exit 0; }
|
||||
if tc qdisc show dev "$IFACE" 2>/dev/null | grep -q " noqueue"; then
|
||||
echo "$IFACE 为 noqueue(容器环境),跳过 qdisc 与网卡 offload 设置"
|
||||
exit 0
|
||||
fi
|
||||
tc qdisc replace dev "$IFACE" root fq 2>/dev/null && echo "已设置 $IFACE root qdisc = fq" || echo "设置 qdisc 失败(可忽略)"
|
||||
ethtool -K "$IFACE" rx-udp-gro-forwarding on 2>/dev/null && echo "已开启 $IFACE rx-udp-gro-forwarding" || echo "$IFACE 不支持 rx-udp-gro-forwarding"
|
||||
ethtool -K "$IFACE" rx-gro-list on 2>/dev/null && echo "已开启 $IFACE rx-gro-list" || echo "$IFACE 不支持 rx-gro-list"
|
||||
@@ -0,0 +1,38 @@
|
||||
# ============================================================
|
||||
# RRPP 节点系统级网络优化(KVM / 完整虚拟机版)
|
||||
# 部署位置:/etc/sysctl.d/99-zz-rrpp-network.conf
|
||||
# 配套:systemd/rrpp-qdisc.service + scripts/rrpp-qdisc.sh(fq 队列 + UDP GRO)
|
||||
# 文件名 99-zz- 前缀保证最后生效;注意 /etc/sysctl.conf 会更晚加载(见 README §2.6)
|
||||
# ============================================================
|
||||
|
||||
# --- 拥塞控制与队列 ---
|
||||
net.ipv4.tcp_congestion_control = bbr
|
||||
net.core.default_qdisc = fq
|
||||
|
||||
# --- PMTUD 黑洞缓解 ---
|
||||
net.ipv4.tcp_mtu_probing = 1
|
||||
|
||||
# --- 握手 / 时延 ---
|
||||
net.ipv4.tcp_fastopen = 3
|
||||
net.ipv4.tcp_slow_start_after_idle = 0
|
||||
|
||||
# --- 连接生命周期 ---
|
||||
net.ipv4.tcp_tw_reuse = 2
|
||||
net.ipv4.tcp_fin_timeout = 15
|
||||
net.ipv4.tcp_keepalive_time = 300
|
||||
net.ipv4.tcp_keepalive_intvl = 30
|
||||
net.ipv4.tcp_keepalive_probes = 5
|
||||
net.ipv4.ip_local_port_range = 10240 65000
|
||||
|
||||
# --- 队列深度与缓冲(TCP)---
|
||||
net.core.somaxconn = 32768
|
||||
net.ipv4.tcp_max_syn_backlog = 32768
|
||||
net.core.netdev_max_backlog = 32768
|
||||
net.core.rmem_max = 33554432
|
||||
net.core.wmem_max = 33554432
|
||||
net.ipv4.tcp_rmem = 4096 131072 33554432
|
||||
net.ipv4.tcp_wmem = 4096 16384 33554432
|
||||
|
||||
# --- UDP(Hysteria2 / TUIC / UoT 的 socket 缓冲下限)---
|
||||
net.ipv4.udp_rmem_min = 8192
|
||||
net.ipv4.udp_wmem_min = 8192
|
||||
@@ -0,0 +1,22 @@
|
||||
# ============================================================
|
||||
# RRPP 节点系统级网络优化(LXC / OpenVZ 容器版)
|
||||
# 部署位置:/etc/sysctl.d/99-zz-rrpp-network.conf
|
||||
# 容器差异:net.core.rmem_max / wmem_max / default_qdisc / netdev_max_backlog
|
||||
# 缺失或只读;eth0 为 noqueue,qdisc/网卡 offload 不可设(由宿主机控制)。
|
||||
# net.ipv4.udp_rmem_min / udp_wmem_min 在部分容器内只读,若报错可忽略。
|
||||
# ============================================================
|
||||
|
||||
net.ipv4.tcp_congestion_control = bbr
|
||||
net.ipv4.tcp_mtu_probing = 1
|
||||
net.ipv4.tcp_fastopen = 3
|
||||
net.ipv4.tcp_slow_start_after_idle = 0
|
||||
net.ipv4.tcp_tw_reuse = 2
|
||||
net.ipv4.tcp_fin_timeout = 15
|
||||
net.ipv4.tcp_keepalive_time = 300
|
||||
net.ipv4.tcp_keepalive_intvl = 30
|
||||
net.ipv4.tcp_keepalive_probes = 5
|
||||
net.ipv4.ip_local_port_range = 10240 65000
|
||||
net.core.somaxconn = 32768
|
||||
net.ipv4.tcp_max_syn_backlog = 32768
|
||||
net.ipv4.udp_rmem_min = 8192
|
||||
net.ipv4.udp_wmem_min = 8192
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=RRPP: set fq qdisc on default-route interface (BBR companion)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/local/sbin/rrpp-qdisc.sh
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -13,18 +13,29 @@ RRPP UoT v2 relay — UDP over TCP 解封装服务(sing-box UoT v2 协议)
|
||||
non-connect 帧: | ATYP | address | port | length u16be | data |
|
||||
|
||||
监听 127.0.0.1,仅供本机 nginx proxy_connect 转发,不对公网开放。
|
||||
|
||||
健壮性设计(2026-09 修复,实测复现过隧道被误杀):
|
||||
* 一律使用「未连接」UDP socket(sendto/recvfrom)——连接式 socket 会把目标侧
|
||||
ICMP(port unreachable 等)转成 ConnectionRefusedError,在 send/recv 上抛异常,
|
||||
旧实现因此把整条 UoT 隧道关掉(客户端表现为 "UoT TCP 流已关闭")。
|
||||
* 数据报级错误(BlockingIOError/EAGAIN、ENETUNREACH、EHOSTUNREACH、EMSGSIZE
|
||||
等)只丢当前包并继续;EAGAIN 先等可写再重试(有总时限)。
|
||||
* 只有 TCP 侧 EOF、UoT 协议错误或空闲超时才关闭隧道。
|
||||
* 按地址族惰性建 socket,non-connect 模式可混用 IPv4/IPv6 目标。
|
||||
"""
|
||||
import errno
|
||||
import select
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import select
|
||||
import time
|
||||
import sys
|
||||
|
||||
LISTEN_HOST = "127.0.0.1"
|
||||
LISTEN_PORT = 15201
|
||||
IDLE_TIMEOUT = 540 # 9 分钟无流量断开(须 < nginx proxy_connect_data_timeout 10m)
|
||||
IDLE_TIMEOUT = 540 # 9 分钟无流量断开(须 < nginx proxy_connect 隧道超时 10m)
|
||||
MAX_DGRAM = 65507 # UDP over IPv4 单包上限
|
||||
SEND_RETRY_TOTAL = 1.0 # EAGAIN 时最多等待 1 秒可写,超时则丢弃该包
|
||||
RECV_ERROR_STREAK_LIMIT = 200 # 连续 recv 错误上限(防病态自旋),超过视为隧道异常
|
||||
LOG_PREFIX = "[uot-relay]"
|
||||
|
||||
|
||||
@@ -74,21 +85,76 @@ def encode_addr(addr, port):
|
||||
return b"\x02" + bytes([len(d)]) + d + struct.pack(">H", port)
|
||||
|
||||
|
||||
def norm_src(src):
|
||||
"""recvfrom 地址归一化:IPv4 为 (host, port),IPv6 为 4 元组。"""
|
||||
return src[0], src[1]
|
||||
|
||||
|
||||
class UdpPool:
|
||||
"""按地址族惰性创建「未连接」UDP socket(v4/v6 可混用)。"""
|
||||
|
||||
def __init__(self):
|
||||
self.socks = {}
|
||||
|
||||
def get(self, addr):
|
||||
fam = socket.AF_INET6 if ":" in addr else socket.AF_INET
|
||||
s = self.socks.get(fam)
|
||||
if s is None:
|
||||
s = socket.socket(fam, socket.SOCK_DGRAM)
|
||||
s.setblocking(False)
|
||||
self.socks[fam] = s
|
||||
return s
|
||||
|
||||
def all(self):
|
||||
return list(self.socks.values())
|
||||
|
||||
def close(self):
|
||||
for s in self.socks.values():
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.socks.clear()
|
||||
|
||||
|
||||
def send_dgram(sock, data, dst):
|
||||
"""发送一个 UDP 数据报。
|
||||
|
||||
返回 True=已发出,False=本包丢弃(**不是**连接级错误,调用方继续跑)。
|
||||
EAGAIN 时等可写重试(有总时限);其余 OSError 一律丢弃本包。
|
||||
"""
|
||||
deadline = time.time() + SEND_RETRY_TOTAL
|
||||
while True:
|
||||
try:
|
||||
sock.sendto(data, dst)
|
||||
return True
|
||||
except BlockingIOError:
|
||||
if time.time() >= deadline:
|
||||
return False
|
||||
try:
|
||||
select.select([], [sock], [], 0.1)
|
||||
except Exception:
|
||||
return False
|
||||
except InterruptedError:
|
||||
continue
|
||||
except OSError:
|
||||
# ENETUNREACH / EHOSTUNREACH / EMSGSIZE / EACCES ...
|
||||
return False
|
||||
|
||||
|
||||
def handle(conn, peer):
|
||||
conn.settimeout(IDLE_TIMEOUT)
|
||||
f = conn.makefile("rb")
|
||||
udp = None
|
||||
pool = UdpPool()
|
||||
stop = threading.Event()
|
||||
fixed_dst = None
|
||||
try:
|
||||
is_connect = read_exact(f, 1)[0]
|
||||
req_dst = parse_addr(f)
|
||||
log(f"conn from nginx: isConnect={is_connect} dst={req_dst}")
|
||||
|
||||
udp = socket.socket(socket.AF_INET6 if ":" in req_dst[0] else socket.AF_INET,
|
||||
socket.SOCK_DGRAM)
|
||||
udp.setblocking(False)
|
||||
if is_connect:
|
||||
udp.connect(req_dst) # 固定目标:连通式 UDP
|
||||
fixed_dst = req_dst
|
||||
pool.get(fixed_dst[0]) # 预建 socket:主循环首轮才有可 select 的对象
|
||||
|
||||
write_lock = threading.Lock()
|
||||
|
||||
@@ -97,7 +163,7 @@ def handle(conn, peer):
|
||||
conn.sendall(data)
|
||||
|
||||
def uplink():
|
||||
"""TCP -> UDP:按格式读帧并发送。"""
|
||||
"""TCP -> UDP:逐帧解析并发送;单包错误只丢包,不关隧道。"""
|
||||
try:
|
||||
while not stop.is_set():
|
||||
if is_connect:
|
||||
@@ -105,34 +171,65 @@ def handle(conn, peer):
|
||||
if ln > MAX_DGRAM:
|
||||
raise ValueError(f"datagram too large: {ln}")
|
||||
data = read_exact(f, ln)
|
||||
udp.send(data)
|
||||
send_dgram(pool.get(fixed_dst[0]), data, fixed_dst)
|
||||
else:
|
||||
dst = parse_addr(f)
|
||||
ln = struct.unpack(">H", read_exact(f, 2))[0]
|
||||
if ln > MAX_DGRAM:
|
||||
raise ValueError(f"datagram too large: {ln}")
|
||||
data = read_exact(f, ln)
|
||||
udp.sendto(data, dst)
|
||||
send_dgram(pool.get(dst[0]), data, dst)
|
||||
except Exception as e:
|
||||
if not stop.is_set():
|
||||
log(f"uplink end: {type(e).__name__}: {e}")
|
||||
finally:
|
||||
stop.set()
|
||||
|
||||
t = threading.Thread(target=uplink, daemon=True)
|
||||
t.start()
|
||||
threading.Thread(target=uplink, daemon=True).start()
|
||||
|
||||
# 主循环:UDP -> TCP,带空闲超时
|
||||
# 主循环:UDP -> TCP(带空闲超时)
|
||||
last = time.time()
|
||||
err_streak = 0
|
||||
while not stop.is_set():
|
||||
r, _, _ = select.select([udp], [], [], 2.0)
|
||||
if r:
|
||||
socks = pool.all()
|
||||
if not socks:
|
||||
# non-connect 模式:socket 由第一帧触发创建,空池时等待而非关连接
|
||||
if time.time() - last > IDLE_TIMEOUT:
|
||||
log("idle timeout (no datagram), closing")
|
||||
break
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
try:
|
||||
r, _, _ = select.select(socks, [], [], 2.0)
|
||||
except OSError:
|
||||
break
|
||||
got = False
|
||||
for s in r:
|
||||
try:
|
||||
data, src = s.recvfrom(MAX_DGRAM)
|
||||
except (BlockingIOError, InterruptedError):
|
||||
continue
|
||||
except OSError as e:
|
||||
# 单包级错误(含 ICMP 触发的错误):丢包继续,不关隧道
|
||||
err_streak += 1
|
||||
if err_streak % 50 == 1:
|
||||
log(f"recv error (datagram dropped, streak={err_streak}): "
|
||||
f"{errno.errorcode.get(e.errno, e.errno)} {e}")
|
||||
if err_streak >= RECV_ERROR_STREAK_LIMIT:
|
||||
log("recv error streak limit reached, closing tunnel")
|
||||
stop.set()
|
||||
break
|
||||
continue
|
||||
err_streak = 0
|
||||
a, p = norm_src(src)
|
||||
if is_connect:
|
||||
data = udp.recv(MAX_DGRAM)
|
||||
if (a, p) != fixed_dst:
|
||||
continue # 固定目标模式:只回传目标自身的包
|
||||
tcp_write(struct.pack(">H", len(data)) + data)
|
||||
else:
|
||||
data, src = udp.recvfrom(MAX_DGRAM)
|
||||
tcp_write(encode_addr(*src) + struct.pack(">H", len(data)) + data)
|
||||
tcp_write(encode_addr(a, p) + struct.pack(">H", len(data)) + data)
|
||||
got = True
|
||||
if got:
|
||||
last = time.time()
|
||||
elif time.time() - last > IDLE_TIMEOUT:
|
||||
log("idle timeout, closing")
|
||||
@@ -149,11 +246,7 @@ def handle(conn, peer):
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if udp:
|
||||
try:
|
||||
udp.close()
|
||||
except Exception:
|
||||
pass
|
||||
pool.close()
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
Reference in New Issue
Block a user