Compare commits

..
18 Commits
Author SHA1 Message Date
RoyirinRosmontis a563cf30f3 docs: nodes.json 位置改回后端目录(与 app.py 同级),同步 API.md 2026-10-09 12:46:08 +08:00
RoyirinRosmontisandRRRosmontis 813e54c919 server_config: §6.2 记录纯 IPv4 节点的 domain_resolver 必修项(jp 案例)
- jp(Azure 无 IPv6)双栈域名曾拨 AAAA 导致 network is unreachable 刷屏;修复采用 1.12+ 新写法
  dns.servers(local) + outbound.domain_resolver(strategy=ipv4_only)
- 明确 legacy domain_strategy 字段在 sing-box 1.13 已 FATAL 拒绝,禁止使用
- 注明 ru/hk2/us/aq 经实测无此问题,无需配置
2026-10-01 23:14:51 +08:00
RoyirinRosmontis 6f2e640881 更新 README.md 2026-09-23 14:49:13 +08:00
RoyirinRosmontis fa45751d33 更新 README.md 2026-09-23 14:48:19 +08:00
RoyirinRosmontis b44d50ee4d server_config: UDP 传输回滚(抗封优先),§6.4 转为留档
- 五节点撤除 Hysteria2/TUIC 入站,sing-box 恢复 vless+ss 原状(UDP 入站不再监听)
- §2.6 UDP 策略定稿:不部署 UDP 传输服务,UDP 需求走 UoT;保留 UDP 系统调优项
- 记录撤除理由(非标 UDP 端口封禁代价近零、CN 侧 QoS、IP 被烧会连带打死 TCP 服务);明确加密性无问题、风险在可封禁面
- §6.4 标注「已回滚,本节为留档」,保留配置片段/各节点 NAT·NSG 差异/端到端验证方法
- §10 验证清单中的 UDP 项标注仅适用于重新启用场景
2026-09-20 22:12:42 +08:00
RoyirinRosmontis 1cc4da8318 server_config: 如实记录 UDP 接入现状(服务端已部署、订阅不下发、无 fallback 组)
- §6.4 新增「接入现状」表:订阅无 hy2/tuic、代理组仅 select、lite 客户端仅 HTTP CONNECT+UoT、UDP 目前只能手工导入
- 记录日后接入订阅所需三步(nodes.json 字段 / app.py 生成 / fallback 组 REALITY 优先)及 fallback 非瞬时切换的注意点
2026-09-20 22:10:20 +08:00
RoyirinRosmontis 8809e40ec1 server_config: 启用 UDP 传输(Hysteria2/TUIC)+ UDP 系统调优,如实记录 KVM/LXC/NAT/NSG 差异
- README 新增 §6.4:Hysteria2/TUIC 配置片段、带宽声明与 Brutal 取舍、客户端链接格式、端到端验证方法
- 如实记录各节点差异(2026-09-20 实测):
  * hk2 NAT 段 4301-4320 UDP 1:1
  * ru(LXC NAT)外部只有宿主映射的 UDP 端口可达(23336+ 实测 connection refused),最终复用与 TCP 同号的 23333/23334
  * jp(Azure)NSG 仅放行 UDP 8445 → 单节点只能承载一个 UDP 协议,保留 TUIC
  * aq 为 IPv6-only,listen 用 ::
- §2.6 UDP 策略修订:由「不部署 UDP」改为「已启用 UDP 传输,同时保留 UoT」
- sysctl 模板补 udp_rmem_min/udp_wmem_min;rrpp-qdisc.sh 增加 rx-udp-gro-forwarding / rx-gro-list(aq 内核 5.10 不支持前者,LXC 无法设置)
- §10 验证清单补 UDP 项,并强调「只看监听不足以证明外部可达」
2026-09-20 22:05:57 +08:00
RoyirinRosmontis 375153b810 server_config: 系统级网络优化规范(KVM/LXC 差异 + qdisc 持久化 + sysctl.conf 覆盖坑)
- 新增 sysctl/99-zz-rrpp-network.{kvm,lxc}.conf 两套模板(99-zz- 前缀保证最后生效)
- 新增 systemd/rrpp-qdisc.service + scripts/rrpp-qdisc.sh(default_qdisc 只对新建网卡生效,需显式 replace)
- README §2.6:如实记录实测差异
  * KVM/完整VM:rmem/wmem、netdev_max_backlog、default_qdisc、qdisc 均可写
  * LXC/OpenVZ:上述项缺失或只读、eth0 为 noqueue、/usr/lib/sysctl.d 的 pid_max/fs.protected_* 报 permission denied(正常)
  * 坑:sysctl --system 会在 sysctl.d 之后加载 /etc/sysctl.conf,实测某节点旧文件把 tcp_mtu_probing 覆盖回 0
- 记录 UDP 策略:节点不部署 UDP 传输,UDP 需求统一走 UoT v2(§5.5)
- 更新 §9 服务表、§10 验证清单、§11 目录索引
2026-09-20 21:50:13 +08:00
DSH Agent 9a64853db4 server_config: 新增 RRPP 订阅后端规则集下发文档(PAC → classical 规则集)
- 新增 §12:PAC 不能直接下发的原因、三个规则集产物、PAC 更新后的再生成流程、
  mihomo/Shadowrocket 两种客户端接入方式与验证口径
- 新增 rrpp-subscription/:API.md(订阅端点/method/rules 参数/规则集说明)
  与 gen_rulesets.py(PAC → 客户端规则集转换脚本,幂等、原子替换)
- method 新增 shadowrocket(.conf:远程 RULE-SET + FINAL,不猜测 REALITY 参数)
- method=clash 新增 &rules=provider|inline|global
2026-09-19 18:46:54 +08:00
RRRosmontis bdd9d3498d fix(uot-relay): 数据报级错误不再杀隧道 + 地址族健壮性
实测复现:目标为关闭的 UDP 端口时,连接式 socket 收到 ICMP port unreachable
→ ConnectionRefusedError → 被兜底 except 接住并 stop.set() → 整条 UoT 隧道被关
(客户端见 'UoT TCP 流已关闭',多隧道同秒集体死亡)。

修复:
- 一律用未连接 UDP socket(不接收 ICMP 错误)
- send/recv 的数据报级错误只丢包继续;EAGAIN 等可写重试(1s 上限)
- 按地址族惰性建 socket(v4/v6 混用)、IPv6 recvfrom 4 元组归一化
- connect 模式预建 socket(修掉主循环空池即关连接的回归)

五节点已部署并验证:狂发 200 包到关闭端口/无路由目标后隧道仍存活;
UoT DNS 端到端 JP/US/AQ/HK2 通过,RU 为该节点出口 UDP 限制(实测 4/10)
导致的偶发超时,非 relay 问题。
2026-09-19 16:41:19 +08:00
RoyirinRosmontis 41204df78a release: publish 9999091904 2026-09-19 04:43:17 +08:00
RoyirinRosmontis d0e739eff1 fix(lite): hide tun network command windows 2026-09-19 04:36:54 +08:00
RoyirinRosmontis e406d614b7 refactor(lite): keep tunnel startup details out of main log 2026-09-19 04:29:24 +08:00
RoyirinRosmontis 357fd26a49 fix(lite): bundle tun runtime and quiet connection logs 2026-09-19 04:27:20 +08:00
RoyirinRosmontis 9d5bc527d3 feat(lite): add opt-in tun ipv6 mode 2026-09-19 04:20:34 +08:00
RoyirinRosmontis 2315735979 fix(lite): report tun worker startup errors 2026-09-19 04:07:27 +08:00
RoyirinRosmontis 889dcb88e5 style(lite): restore intended mode hover contrast 2026-09-19 04:01:02 +08:00
RoyirinRosmontis 9224ad33fe style(lite): swap mode card hover colors 2026-09-19 03:54:59 +08:00
16 changed files with 1054 additions and 106 deletions
+8 -12
View File
@@ -1,17 +1,13 @@
# Royirin Rosmontis Private Proxy # Royirin Rosmontis Private Proxy
Supported by Global Rosmanka Cloud Nexus Inc. —— https://rosmanka.rrrosmontis.icu Supported by Global Rosmanka Cloud Nexus Inc. —— https://rosmanka.rrrosmontis.icu
## 警告 ## 警告
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本。common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新 - 自构建的客户端永远不再维护。推荐使用 Clash Verge 或其他软件结合服务器端配置使用。
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows 应用 (.exe) > 这些客户端使用了 HTTP 直接连接,具有流量识别的安全隐患。
- 因为 Windows 系统的安全机制,Release 发布的所有程序都为 Pyinstaller 打包版本,故启动速度可能较慢,尤其是首次启动。相对而言,Nuitka 打包的版本启动较快,但可能仅能在自己电脑上运行。如果需要 Nuitka 版本,仓库提供了对应的打包程序,请自备虚拟环境 - 其中,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 文件手册 - 更推荐参考本仓库 server_config 文件夹中的 md 文件手册
## 若要直接使用仓库客户端对应的服务
请前往 https://sso.rrrosmontis.icu 注册 Rosmanka ID,然后与管理员商定使用细则
royirin@rrrosmontis.icu
+2
View File
@@ -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.tun2socks.txt=LICENSE.tun2socks.txt ^
--include-data-files=runtime/LICENSE.wintun.txt=LICENSE.wintun.txt ^ --include-data-files=runtime/LICENSE.wintun.txt=LICENSE.wintun.txt ^
--include-data-files=runtime/README.txt=README.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-icon-from-ico=ico.ico ^
--windows-console-mode=disable ^ --windows-console-mode=disable ^
--company-name="Global Rosmanka Cloud Nexus Inc" ^ --company-name="Global Rosmanka Cloud Nexus Inc" ^
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"version": "999909190333", "version": "999909190441",
"pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/", "pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
"nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client", "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" "update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest"
+29 -15
View File
@@ -1056,7 +1056,7 @@ def _verify_proxy_connectivity(timeout=10, opener=None, cancel_event=None):
response.read(1) response.read(1)
_raise_if_start_cancelled(cancel_event) _raise_if_start_cancelled(cancel_event)
results[name] = True results[name] = True
log(f" {name} 可达") log_detail(f" {name} 可达")
except _StartCancelled: except _StartCancelled:
raise raise
except Exception as e: 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) _raise_if_start_cancelled(cancel_event)
results[name] = False results[name] = False
log(f" {name} 不可达: {e}") log_detail(f" {name} 不可达: {e}")
if required: if required:
raise RuntimeError(f"境外隧道验证失败({name}):{e}") from e raise RuntimeError(f"境外隧道验证失败({name}):{e}") from e
return results return results
@@ -1116,7 +1116,7 @@ def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, sma
svr.close() svr.close()
svr = None svr = None
if attempt < 2: 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) time.sleep(1)
if svr is None: if svr is None:
raise last_err 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: if not quiet:
route = "→".join(str(node.get("country") or "?") for node in (jump_chain or [])) route = "→".join(str(node.get("country") or "?") for node in (jump_chain or []))
suffix = f" [{route}]" if route else "" 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: def _inject_proxy_auth(data: bytes, proxy_node=None) -> bytes:
"""在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。 """在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。
@@ -1312,6 +1312,15 @@ def _set_tun_dns_servers(value):
data["tun_dns"] = list(parsed) data["tun_dns"] = list(parsed)
_settings_save(data) _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(): def _use_system_pac():
"""只决定 Windows 系统代理是否使用 PAC;旧设置按原行为读取。""" """只决定 Windows 系统代理是否使用 PAC;旧设置按原行为读取。"""
data = _settings_load() data = _settings_load()
@@ -2250,7 +2259,7 @@ def _connect_route(direct_target, allow_direct_fallback, remote_host, remote_por
except OSError as e: except OSError as e:
if not allow_direct_fallback: if not allow_direct_fallback:
raise raise
log(f"[转发器] 国内直连 {direct_target[0]} 失败,回退上游隧道: {e}") log_detail(f"[转发器] 国内直连 {direct_target[0]} 失败,回退上游隧道: {e}")
if jump_chain: if jump_chain:
if not use_tls: if not use_tls:
raise RuntimeError("跳板模式仅支持 TLS 上游") raise RuntimeError("跳板模式仅支持 TLS 上游")
@@ -2372,7 +2381,7 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
else: else:
log_detail(f"[连接 #{connection_id}] 无法解析目标: {first_line or '空请求'},客户端={peer_text}") log_detail(f"[连接 #{connection_id}] 无法解析目标: {first_line or '空请求'},客户端={peer_text}")
except Exception as e: except Exception as e:
log(f"[转发器] 读取首包失败: {e}") log_detail(f"[转发器] 读取首包失败: {e}")
log_detail(f"[连接 #{connection_id}] 失败: 读取首包,目标={target_text},规则匹配={rule_match},错误={e}") log_detail(f"[连接 #{connection_id}] 失败: 读取首包,目标={target_text},规则匹配={rule_match},错误={e}")
client.close() client.close()
return return
@@ -2405,7 +2414,7 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
if via_tunnel: if via_tunnel:
_traffic_add(up=len(buf)) _traffic_add(up=len(buf))
except Exception as e: except Exception as e:
log(f"[转发器] 连接目标失败: {e}") log_detail(f"[转发器] 连接目标失败: {e}")
log_detail(f"[连接 #{connection_id}] 连接失败: 目标={target_text},规则匹配={rule_match},路径={route_summary},错误={e}") log_detail(f"[连接 #{connection_id}] 连接失败: 目标={target_text},规则匹配={rule_match},路径={route_summary},错误={e}")
try: try:
client.close() client.close()
@@ -2632,6 +2641,7 @@ def _settings_services():
get_port=lambda: _TLS_LOCAL_PORT, get_port=lambda: _TLS_LOCAL_PORT,
get_system_pac=_use_system_pac, get_system_pac=_use_system_pac,
get_dns=_tun_dns_servers, get_dns=_tun_dns_servers,
get_ipv6_enabled=_tun_ipv6_enabled,
get_update_via_proxy=_update_via_proxy, get_update_via_proxy=_update_via_proxy,
get_skip_update_check=_skip_update_check, get_skip_update_check=_skip_update_check,
get_node_display_mode=_node_display_mode, get_node_display_mode=_node_display_mode,
@@ -2640,6 +2650,7 @@ def _settings_services():
set_port=_set_local_port, set_port=_set_local_port,
set_system_pac=_set_use_system_pac, set_system_pac=_set_use_system_pac,
set_dns=_set_tun_dns_servers, set_dns=_set_tun_dns_servers,
set_ipv6_enabled=_set_tun_ipv6_enabled,
set_update_via_proxy=_set_update_via_proxy, set_update_via_proxy=_set_update_via_proxy,
set_skip_update_check=_set_skip_update_check, set_skip_update_check=_set_skip_update_check,
set_node_display_mode=_set_node_display_mode, set_node_display_mode=_set_node_display_mode,
@@ -2680,7 +2691,8 @@ def _make_tun_backend(emit):
engine = Path(BASE_DIR) / "runtime" / "tun2socks.exe" engine = Path(BASE_DIR) / "runtime" / "tun2socks.exe"
if not engine.is_file() or not (engine.parent / "wintun.dll").is_file(): if not engine.is_file() or not (engine.parent / "wintun.dll").is_file():
raise RuntimeError("单文件包中的 tun2socks.exe 或 wintun.dll 缺失") 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): def _start_tun_backend(backend, payload):
@@ -2722,6 +2734,7 @@ def _start_tun_backend(backend, payload):
bypass_lan=bypass_lan, bypass_lan=bypass_lan,
route_rules=rules, route_rules=rules,
dns_servers=payload["dns_servers"], dns_servers=payload["dns_servers"],
ipv6_enabled=bool(payload.get("ipv6_enabled", False)),
direct_target_resolver=direct_target, direct_target_resolver=direct_target,
route_rule_resolver=user_rule, route_rule_resolver=user_rule,
route_node_resolver=route_node, route_node_resolver=route_node,
@@ -2748,6 +2761,7 @@ def _tun_start_payload(use_pac, bypass_lan):
"route_rules": _route_rules(), "route_rules": _route_rules(),
"route_nodes": route_nodes, "route_nodes": route_nodes,
"dns_servers": _tun_dns_servers(), "dns_servers": _tun_dns_servers(),
"ipv6_enabled": _tun_ipv6_enabled(),
"bypass_lan": bool(bypass_lan), "bypass_lan": bool(bypass_lan),
"smart": bool(use_pac), "smart": bool(use_pac),
} }
@@ -2770,7 +2784,7 @@ class MainWindow(QWidget):
super().__init__() super().__init__()
self._proxy_set_by_us = False # 系统代理是否由本程序开启(退出时只清理自己设置的) self._proxy_set_by_us = False # 系统代理是否由本程序开启(退出时只清理自己设置的)
self._mode = _proxy_mode() 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._quitting = False
self._tray = None self._tray = None
self._tray_menu = None self._tray_menu = None
@@ -3211,11 +3225,11 @@ class MainWindow(QWidget):
_traffic_reset() _traffic_reset()
current_node = _current_node() current_node = _current_node()
jump_chain = _jump_chain(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() ep = _endpoint_domain()
log(f" 目标域名: {ep}") log_detail(f" 目标域名: {ep}")
if jump_chain: if jump_chain:
log(f" 跳板路径: {_jump_route_text(current_node)}") log_detail(f" 跳板路径: {_jump_route_text(current_node)}")
# PAC 模式:全部流量进转发器,按域名表/解析 IP 智能分流 # PAC 模式:全部流量进转发器,按域名表/解析 IP 智能分流
use_internal_pac = _should_use_internal_pac(use_pac) use_internal_pac = _should_use_internal_pac(use_pac)
if use_internal_pac: if use_internal_pac:
@@ -3236,7 +3250,7 @@ class MainWindow(QWidget):
# 在内存中启动本地 PAC HTTP 服务,不往 EXE 目录写任何文件 # 在内存中启动本地 PAC HTTP 服务,不往 EXE 目录写任何文件
pac_port = _start_pac_server(pac_content) pac_port = _start_pac_server(pac_content)
pac_url = f"http://127.0.0.1:{pac_port}/proxy.pac" 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: with self._proxy_state_lock:
_raise_if_start_cancelled(cancel_event) _raise_if_start_cancelled(cancel_event)
proxy_on(pac_url=pac_url) proxy_on(pac_url=pac_url)
@@ -3247,10 +3261,10 @@ class MainWindow(QWidget):
proxy_on(bypass_lan=use_bypass_lan) proxy_on(bypass_lan=use_bypass_lan)
self._proxy_set_by_us = True self._proxy_set_by_us = True
if use_internal_pac: 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) _raise_if_start_cancelled(cancel_event)
if use_bypass_lan: if use_bypass_lan:
log("不代理内网 IP:内网地址将直连") log_detail("不代理内网 IP:内网地址将直连")
log("验证代理连通性...") log("验证代理连通性...")
_verify_proxy_connectivity(cancel_event=cancel_event) _verify_proxy_connectivity(cancel_event=cancel_event)
except Exception: except Exception:
+16 -2
View File
@@ -587,6 +587,20 @@ class Socks5Bridge:
except OSError: except OSError:
pass 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: def _open_target(self, target: Address) -> socket.socket:
policy_host = self._policy_host(target.host) policy_host = self._policy_host(target.host)
rule = self._route_rule(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 rule and str(rule.get("action") or "").lower() == "node":
if route_node is None: if route_node is None:
raise ConnectionError("域名路由指定的节点不可用") 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) direct = self._direct_target_for(target.host, target.port, policy_host)
if direct is not None: if direct is not None:
self._ensure_direct_routes(direct[0], direct[1]) self._ensure_direct_routes(direct[0], direct[1])
return socket.create_connection(direct, 15) 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): def _tcp(self, client: socket.socket, target: Address):
upstream = self._open_target(target) upstream = self._open_target(target)
+62 -34
View File
@@ -27,6 +27,16 @@ class TunBackendError(RuntimeError):
"""TUN 后端无法启动或停止。""" """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: class TunBackend:
def __init__(self, user_dir: str | Path, engine_path: str | Path | None = None, def __init__(self, user_dir: str | Path, engine_path: str | Path | None = None,
log: Callable[[str], None] | None = None, log: Callable[[str], None] | None = None,
@@ -53,6 +63,7 @@ class TunBackend:
self._ipv6_address = "fd42:7272:5050::1" self._ipv6_address = "fd42:7272:5050::1"
self._dns_configured = False self._dns_configured = False
self._tun_dns_servers = ("8.8.8.8", "1.1.1.1") self._tun_dns_servers = ("8.8.8.8", "1.1.1.1")
self._ipv6_enabled = False
self._bypass_lan = True self._bypass_lan = True
self._cleanup_pending = False self._cleanup_pending = False
self._state_path = self.user_dir / "tun_state.json" self._state_path = self.user_dir / "tun_state.json"
@@ -109,7 +120,7 @@ class TunBackend:
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | " "Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
"Sort-Object RouteMetric | Select-Object -First 1 -ExpandProperty InterfaceAlias)"] "Sort-Object RouteMetric | Select-Object -First 1 -ExpandProperty InterfaceAlias)"]
try: 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() value = (result.stdout or "").strip().splitlines()
if value and value[0].strip(): if value and value[0].strip():
return value[0].strip() return value[0].strip()
@@ -126,7 +137,7 @@ class TunBackend:
f"(Get-NetIPAddress -InterfaceAlias '{escaped}' -AddressFamily IPv4 " f"(Get-NetIPAddress -InterfaceAlias '{escaped}' -AddressFamily IPv4 "
"-AddressState Preferred | Select-Object -First 1 -ExpandProperty IPAddress)"] "-AddressState Preferred | Select-Object -First 1 -ExpandProperty IPAddress)"]
try: 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() value = (result.stdout or "").strip().splitlines()
if value and value[0].strip(): if value and value[0].strip():
return value[0].strip() return value[0].strip()
@@ -143,7 +154,7 @@ class TunBackend:
"Sort-Object RouteMetric | Select-Object -First 1 " "Sort-Object RouteMetric | Select-Object -First 1 "
"NextHop,InterfaceIndex | ConvertTo-Json -Compress"] "NextHop,InterfaceIndex | ConvertTo-Json -Compress"]
try: 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()) data = json.loads((result.stdout or "").strip())
if isinstance(data, dict): if isinstance(data, dict):
return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "") return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "")
@@ -160,7 +171,7 @@ class TunBackend:
"Sort-Object RouteMetric | Select-Object -First 1 " "Sort-Object RouteMetric | Select-Object -First 1 "
"NextHop,InterfaceIndex | ConvertTo-Json -Compress"] "NextHop,InterfaceIndex | ConvertTo-Json -Compress"]
try: 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()) data = json.loads((result.stdout or "").strip())
if isinstance(data, dict): if isinstance(data, dict):
return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "") return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "")
@@ -181,7 +192,7 @@ class TunBackend:
"$_.IPAddress -notlike 'fd*' }).IPAddress | ConvertTo-Json -Compress", "$_.IPAddress -notlike 'fd*' }).IPAddress | ConvertTo-Json -Compress",
] ]
try: 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: if result.returncode:
return None return None
value = json.loads((result.stdout or "").strip() or "[]") value = json.loads((result.stdout or "").strip() or "[]")
@@ -241,7 +252,7 @@ class TunBackend:
if self._physical_gateway: if self._physical_gateway:
command.append(self._physical_gateway) command.append(self._physical_gateway)
command.extend(["METRIC", "1", "IF", self._physical_ifindex]) 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) encoding="mbcs", errors="replace", timeout=15)
if result.returncode: if result.returncode:
detail = (result.stderr or result.stdout or "").strip() detail = (result.stderr or result.stdout or "").strip()
@@ -251,6 +262,10 @@ class TunBackend:
continue continue
self._node_routes.append(address) self._node_routes.append(address)
self._write_state() 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: if candidates_v6 and not self._physical_ipv6_ifindex:
# 双栈节点仍可使用 IPv4 地址;只有 IPv6-only 节点需要物理 IPv6 出口。 # 双栈节点仍可使用 IPv4 地址;只有 IPv6-only 节点需要物理 IPv6 出口。
if candidates_v4: if candidates_v4:
@@ -270,7 +285,7 @@ class TunBackend:
if self._physical_ipv6_gateway and self._physical_ipv6_gateway != "::": if self._physical_ipv6_gateway and self._physical_ipv6_gateway != "::":
command.append(f"nexthop={self._physical_ipv6_gateway}") command.append(f"nexthop={self._physical_ipv6_gateway}")
command.append("metric=1") 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) encoding="mbcs", errors="replace", timeout=15)
if result.returncode: if result.returncode:
detail = (result.stderr or result.stdout or "").strip() detail = (result.stderr or result.stdout or "").strip()
@@ -289,6 +304,8 @@ class TunBackend:
except ValueError: except ValueError:
return return
if parsed.version == 6: if parsed.version == 6:
if not self._ipv6_enabled:
raise TunBackendError("IPv6 模式未启用,无法添加 IPv6 物理路由")
normalized = parsed.compressed normalized = parsed.compressed
if normalized in self._node_ipv6_routes: if normalized in self._node_ipv6_routes:
return return
@@ -303,7 +320,7 @@ class TunBackend:
if self._physical_gateway: if self._physical_gateway:
command.append(self._physical_gateway) command.append(self._physical_gateway)
command.extend(["METRIC", "1", "IF", self._physical_ifindex]) 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) encoding="mbcs", errors="replace", timeout=15)
detail = (result.stderr or result.stdout or "").strip() detail = (result.stderr or result.stdout or "").strip()
if result.returncode: if result.returncode:
@@ -326,7 +343,7 @@ class TunBackend:
def _cleanup_netsh(self, args: list[str]) -> bool: def _cleanup_netsh(self, args: list[str]) -> bool:
try: 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) encoding="mbcs", errors="replace", timeout=15)
except Exception as exc: except Exception as exc:
self.detail_log(f"[TUN] 清理 netsh 失败: {exc}") self.detail_log(f"[TUN] 清理 netsh 失败: {exc}")
@@ -341,7 +358,7 @@ class TunBackend:
remaining: list[str] = [] remaining: list[str] = []
for address in self._node_routes: for address in self._node_routes:
try: 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) text=True, encoding="mbcs", errors="replace", timeout=15)
except Exception as exc: except Exception as exc:
self.detail_log(f"[TUN] 清理节点路由失败 ({address}): {exc}") self.detail_log(f"[TUN] 清理节点路由失败 ({address}): {exc}")
@@ -358,7 +375,7 @@ class TunBackend:
if self._physical_ipv6_ifindex: if self._physical_ipv6_ifindex:
command.append(f"interface={self._physical_ipv6_ifindex}") command.append(f"interface={self._physical_ipv6_ifindex}")
try: 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) encoding="mbcs", errors="replace", timeout=15)
except Exception as exc: except Exception as exc:
self.detail_log(f"[TUN] 清理 IPv6 节点路由失败 ({address}): {exc}") self.detail_log(f"[TUN] 清理 IPv6 节点路由失败 ({address}): {exc}")
@@ -372,7 +389,7 @@ class TunBackend:
return not remaining and not remaining_v6 return not remaining and not remaining_v6
def _netsh(self, args: list[str], *, ignore_error: bool = False) -> None: 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) encoding="mbcs", errors="replace", timeout=15)
if result.returncode and not ignore_error: if result.returncode and not ignore_error:
detail = (result.stderr or result.stdout or "").strip() detail = (result.stderr or result.stdout or "").strip()
@@ -381,7 +398,7 @@ class TunBackend:
def _configure_routes(self) -> None: def _configure_routes(self) -> None:
for _ in range(20): for _ in range(20):
try: try:
probe = subprocess.run( probe = _run_hidden(
["powershell", "-NoProfile", "-Command", ["powershell", "-NoProfile", "-Command",
f"Get-NetAdapter -Name '{self._device}' -ErrorAction SilentlyContinue"], f"Get-NetAdapter -Name '{self._device}' -ErrorAction SilentlyContinue"],
capture_output=True, text=True, timeout=3, capture_output=True, text=True, timeout=3,
@@ -400,14 +417,15 @@ class TunBackend:
f"{self._device}", self._gateway, "metric=1"]) f"{self._device}", self._gateway, "metric=1"])
self._route_added = True self._route_added = True
self._write_state() self._write_state()
self._netsh(["interface", "ipv6", "set", "address", if self._ipv6_enabled:
f"interface={self._device}", f"address={self._ipv6_address}", self._netsh(["interface", "ipv6", "set", "address",
"store=active"]) f"interface={self._device}", f"address={self._ipv6_address}",
self._netsh(["interface", "ipv6", "add", "route", "prefix=::/0", "store=active"])
f"interface={self._device}", f"nexthop={self._ipv6_gateway}", self._netsh(["interface", "ipv6", "add", "route", "prefix=::/0",
"metric=1"]) f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
self._ipv6_route_added = True "metric=1"])
self._write_state() self._ipv6_route_added = True
self._write_state()
if not self._bypass_lan: if not self._bypass_lan:
# Windows 物理网卡通常已有更具体的私网路由;补同前缀的 TUN # Windows 物理网卡通常已有更具体的私网路由;补同前缀的 TUN
# 路由,确保“内网也代理”不会在进入 tun2socks 前被物理网卡截走。 # 路由,确保“内网也代理”不会在进入 tun2socks 前被物理网卡截走。
@@ -417,12 +435,13 @@ class TunBackend:
f"{self._device}", self._gateway, "metric=1"]) f"{self._device}", self._gateway, "metric=1"])
self._lan_routes.append(network) self._lan_routes.append(network)
self._write_state() self._write_state()
for network in ("fc00::/7",): if self._ipv6_enabled:
self._netsh(["interface", "ipv6", "add", "route", f"prefix={network}", for network in ("fc00::/7",):
f"interface={self._device}", f"nexthop={self._ipv6_gateway}", self._netsh(["interface", "ipv6", "add", "route", f"prefix={network}",
"metric=1"]) f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
self._lan_ipv6_routes.append(network) "metric=1"])
self._write_state() self._lan_ipv6_routes.append(network)
self._write_state()
self._configure_dns() self._configure_dns()
def _configure_dns(self) -> None: def _configure_dns(self) -> None:
@@ -430,7 +449,7 @@ class TunBackend:
if os.name != "nt": if os.name != "nt":
return return
ipv4 = [item for item in self._tun_dns_servers if ":" not in item] 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: if ipv4:
self._netsh([ self._netsh([
"interface", "ipv4", "set", "dnsservers", "interface", "ipv4", "set", "dnsservers",
@@ -531,6 +550,7 @@ class TunBackend:
"node_ipv6_routes": list(self._node_ipv6_routes), "node_ipv6_routes": list(self._node_ipv6_routes),
"route_added": bool(self._route_added), "route_added": bool(self._route_added),
"ipv6_route_added": bool(self._ipv6_route_added), "ipv6_route_added": bool(self._ipv6_route_added),
"ipv6_enabled": bool(self._ipv6_enabled),
"dns_configured": bool(self._dns_configured), "dns_configured": bool(self._dns_configured),
"dns_servers": list(self._tun_dns_servers), "dns_servers": list(self._tun_dns_servers),
"cleanup_pending": bool(self._cleanup_pending), "cleanup_pending": bool(self._cleanup_pending),
@@ -587,6 +607,7 @@ class TunBackend:
self._gateway = str(state.get("gateway") or self._gateway) self._gateway = str(state.get("gateway") or self._gateway)
self._ipv6_address = str(state.get("ipv6_address") or self._ipv6_address) 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_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_gateway = str(state.get("physical_ipv6_gateway") or "")
self._physical_ipv6_ifindex = str(state.get("physical_ipv6_ifindex") or "") self._physical_ipv6_ifindex = str(state.get("physical_ipv6_ifindex") or "")
self._route_added = bool(state.get("route_added")) 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, def start(self, node: dict[str, Any], *, bypass_lan: bool = True,
route_rules: list[dict[str, Any]] | None = None, route_rules: list[dict[str, Any]] | None = None,
dns_servers: tuple[str, ...] | list[str] | 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, 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_rule_resolver: Callable[[str], dict[str, Any] | None] | None = None,
route_node_resolver: Callable[[dict[str, Any]], 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(): if not self.is_admin():
raise TunBackendError("启动虚拟网卡 TUN 需要管理员权限") raise TunBackendError("启动虚拟网卡 TUN 需要管理员权限")
self._bypass_lan = bool(bypass_lan) self._bypass_lan = bool(bypass_lan)
self._ipv6_enabled = bool(ipv6_enabled)
if dns_servers is not None: if dns_servers is not None:
cleaned = tuple(str(item).strip() for item in dns_servers if str(item).strip()) cleaned = tuple(str(item).strip() for item in dns_servers if str(item).strip())
if not cleaned: if not cleaned:
raise TunBackendError("自定义 DNS 不能为空") raise TunBackendError("自定义 DNS 不能为空")
try: try:
for item in cleaned: for item in cleaned:
socket.inet_pton(socket.AF_INET, item) address = ipaddress.ip_address(item)
except OSError as exc: if address.is_unspecified or address.is_multicast:
raise TunBackendError("当前 TUN 仅支持 IPv4 DNS 服务器") from exc raise ValueError(item)
except ValueError as exc:
raise TunBackendError("自定义 DNS 必须是有效的 IPv4/IPv6 地址") from exc
self._tun_dns_servers = cleaned[:3] self._tun_dns_servers = cleaned[:3]
engine = self.resolve_engine() engine = self.resolve_engine()
interface = self._physical_interface() interface = self._physical_interface()
source_address = self._physical_source_address(interface) source_address = self._physical_source_address(interface)
self._physical_gateway, self._physical_ifindex = self._physical_route_info() 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: if not self._physical_ifindex:
raise TunBackendError("找不到物理默认路由,无法保证代理节点不进入 TUN 回环") raise TunBackendError("找不到物理默认路由,无法保证代理节点不进入 TUN 回环")
if not self._physical_ipv6_ifindex: if self._ipv6_enabled and not self._physical_ipv6_ifindex:
self.log("警告:未找到物理 IPv6 默认路由,IPv6 目标将仅尝试通过 IPv4 代理节点转发") self.log("警告:未找到物理 IPv6 默认路由,IPv6 目标将仅尝试通过 IPv4 代理节点转发")
try: try:
proxy_addresses_by_node: dict[str, list[tuple]] = {} proxy_addresses_by_node: dict[str, list[tuple]] = {}
@@ -709,7 +737,7 @@ class TunBackend:
# 因此 Windows 不需要 --interface;Linux/macOS 仍保留原行为。 # 因此 Windows 不需要 --interface;Linux/macOS 仍保留原行为。
if interface and os.name != "nt": if interface and os.name != "nt":
command.extend(["--interface", interface]) 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: try:
self._process = subprocess.Popen( self._process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+36 -15
View File
@@ -46,9 +46,11 @@ def _receive(reader) -> dict[str, Any]:
class TunModeController: class TunModeController:
"""普通权限 GUI 仅在首次开启 TUN 时启动管理员工作进程。""" """普通权限 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.command = command
self.log = log self.log = log
self.detail_log = detail_log or log
self._lock = threading.RLock() self._lock = threading.RLock()
self._socket: socket.socket | None = None self._socket: socket.socket | None = None
self._reader = None self._reader = None
@@ -124,17 +126,23 @@ class TunModeController:
def _request(self, command: str, payload: dict[str, Any] | None = None) -> Any: def _request(self, command: str, payload: dict[str, Any] | None = None) -> Any:
if self._socket is None or self._reader is None: if self._socket is None or self._reader is None:
raise TunModeError("TUN 工作进程未连接") raise TunModeError("TUN 工作进程未连接")
_send(self._socket, {"command": command, "payload": payload or {}}) try:
while True: _send(self._socket, {"command": command, "payload": payload or {}})
message = _receive(self._reader) while True:
if message.get("type") == "log": message = _receive(self._reader)
self.log(str(message.get("message") or "")) if message.get("type") == "log":
continue callback = self.detail_log if message.get("detail") else self.log
if message.get("type") != "result": callback(str(message.get("message") or ""))
raise TunModeError("TUN 工作进程响应无效") continue
if not message.get("ok"): if message.get("type") != "result":
raise TunModeError(str(message.get("error") or "TUN 操作失败")) raise TunModeError("TUN 工作进程响应无效")
return message.get("value") 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]: def status(self) -> dict[str, Any]:
with self._lock: with self._lock:
@@ -187,15 +195,24 @@ def run_tun_worker(port: int, token: str, backend_factory,
send_lock = threading.Lock() send_lock = threading.Lock()
_send(connection, {"type": "hello", "token": token}, send_lock) _send(connection, {"type": "hello", "token": token}, send_lock)
def emit(message: str) -> None: def emit(message: str, detail: bool = False) -> None:
try: try:
_send(connection, {"type": "log", "message": str(message)}, send_lock) _send(connection, {"type": "log", "message": str(message),
"detail": bool(detail)}, send_lock)
except OSError: except OSError:
pass 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 backend = None
try: try:
backend = backend_factory(emit)
while True: while True:
try: try:
request = _receive(reader) request = _receive(reader)
@@ -206,6 +223,10 @@ def run_tun_worker(port: int, token: str, backend_factory,
if command == "start": if command == "start":
if not isinstance(request.get("payload"), dict): if not isinstance(request.get("payload"), dict):
raise TunModeError("TUN 启动参数无效") raise TunModeError("TUN 启动参数无效")
# 延迟创建后端,使初始化失败也能通过控制协议返回具体原因,
# 避免 GUI 只看到 Windows 的 10053 连接中止错误。
if backend is None:
backend = backend_factory(logger)
start_backend(backend, request["payload"]) start_backend(backend, request["payload"])
value = None value = None
elif command == "stop": elif command == "stop":
+18
View File
@@ -933,6 +933,7 @@ class SettingsServices:
get_port: object get_port: object
get_system_pac: object get_system_pac: object
get_dns: object get_dns: object
get_ipv6_enabled: object
get_update_via_proxy: object get_update_via_proxy: object
get_skip_update_check: object get_skip_update_check: object
get_node_display_mode: object get_node_display_mode: object
@@ -941,6 +942,7 @@ class SettingsServices:
set_port: object set_port: object
set_system_pac: object set_system_pac: object
set_dns: object set_dns: object
set_ipv6_enabled: object
set_update_via_proxy: object set_update_via_proxy: object
set_skip_update_check: object set_skip_update_check: object
set_node_display_mode: object set_node_display_mode: object
@@ -1114,6 +1116,18 @@ class SettingsDialog(QDialog):
dns_row.addWidget(self.ed_dns) dns_row.addWidget(self.ed_dns)
left_col.addLayout(dns_row) 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_row = QHBoxLayout()
update_label = QLabel("使用代理更新") update_label = QLabel("使用代理更新")
update_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") 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.ed_port.setText(str(self._services.get_port()))
self.cb_system_pac.setChecked(self._services.get_system_pac()) self.cb_system_pac.setChecked(self._services.get_system_pac())
self.ed_dns.setText(", ".join(self._services.get_dns())) 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_update_proxy.setChecked(self._services.get_update_via_proxy())
self.cb_skip_update.setChecked(self._services.get_skip_update_check()) self.cb_skip_update.setChecked(self._services.get_skip_update_check())
self.btn_node_domain.setChecked(True) self.btn_node_domain.setChecked(True)
@@ -1287,6 +1303,8 @@ class SettingsDialog(QDialog):
return return
self._services.set_port(port) self._services.set_port(port)
self._services.set_system_pac(bool(self.cb_system_pac.isChecked())) 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_update_via_proxy(bool(self.cb_update_proxy.isChecked()))
self._services.set_skip_update_check(bool(self.cb_skip_update.isChecked())) self._services.set_skip_update_check(bool(self.cb_skip_update.isChecked()))
self._services.set_node_display_mode( self._services.set_node_display_mode(
+305 -2
View File
@@ -22,6 +22,7 @@
- [9. systemd 服务文件](#9-systemd-服务文件) - [9. systemd 服务文件](#9-systemd-服务文件)
- [10. 启动顺序与验证清单](#10-启动顺序与验证清单) - [10. 启动顺序与验证清单](#10-启动顺序与验证清单)
- [11. 目录结构与文件索引](#11-目录结构与文件索引) - [11. 目录结构与文件索引](#11-目录结构与文件索引)
- [12. RRPP 订阅后端(PAC → 规则集下发)](#12-rrpp-订阅后端pac--规则集下发)
--- ---
@@ -157,6 +158,110 @@ fail2ban-client status sshd # 确认 jail 在跑
> - 只加 fail2ban、**不动 sshd 认证方式**(root 登录等现状保留)。 > - 只加 fail2ban、**不动 sshd 认证方式**(root 登录等现状保留)。
> - 如需同时护 nginx CONNECT 的 401 爆破,可再加 nginx jail(凭据够强时可不做)。 > - 如需同时护 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. 伪装站点页面 ## 3. 伪装站点页面
@@ -328,6 +433,21 @@ magic 地址 → `127.0.0.1:15201`,其余 CONNECT 走 `$connect_addr` 默认
4. 模块**拒绝 CONNECT 端口 0**(`port < 1` 返回 400)→ 客户端连 magic 地址时 4. 模块**拒绝 CONNECT 端口 0**(`port < 1` 返回 400)→ 客户端连 magic 地址时
端口必须非 0(用 443 即可,relay 不读 CONNECT 端口,目标取自 UoT 帧头)。 端口必须非 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),然后 **客户端协议**:认证与 TCP CONNECT 相同(TLS + Basic Auth),然后
`CONNECT sp.v2.udp-over-tcp.arpa:443 HTTP/1.1`,200 之后收发 UoT v2 帧: `CONNECT sp.v2.udp-over-tcp.arpa:443 HTTP/1.1`,200 之后收发 UoT v2 帧:
请求头 `| isConnect u8 | ATYP u8 | address | port u16be |`, 请求头 `| 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)。要点: 完整配置见 [sing-box/config.json](sing-box/config.json)。要点:
- **VLESS+REALITY**:每节点独立 UUID/密钥对/short_id,SNI 全节点统一,flow `xtls-rprx-vision`;REALITY handshake server 用 `<伪装SNI域名>:443` - **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 的老客户端) - **Shadowsocks**:`2022-blake3-aes-256-gcm`(SIP022:会话子密钥 + 抗重放探测,显著优于老 AEAD `aes-256-gcm`)+ 每节点独立 PSK,独立端口(兼容不支持 REALITY 的老客户端)
- **PSK 格式要求**:密码必须是 base64 编码的 32 字节密钥(`openssl rand -base64 32`),不是任意字符串;改错格式 sing-box 启动即报错 - **PSK 格式要求**:密码必须是 base64 编码的 32 字节密钥(`openssl rand -base64 32`),不是任意字符串;改错格式 sing-box 启动即报错
- sing-box / Shadowrocket / mihomo 均支持 2022-blake3 系列 - 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 代理) ## 7. mtg(MTProto 代理)
Telegram 内置代理,Fake TLS 模式抗封锁。用 [mtg](https://github.com/9seconds/mtg)(Go 单二进制): 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) | | sing-box | [systemd/sing-box.service](systemd/sing-box.service) |
| mtg | [systemd/mtg.service](systemd/mtg.service) | | mtg | [systemd/mtg.service](systemd/mtg.service) |
| uot-relay | [uot-relay/uot-relay.service](uot-relay/uot-relay.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 ```bash
cp systemd/*.service /etc/systemd/system/ 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_congestion_control # => bbr
sysctl net.ipv4.tcp_mtu_probing # => 1(PMTUD 黑洞缓解,见 2.4) 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
fail2ban-client status sshd # => jail 存在(见 2.5) 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 地址) # UoT relay(127.0.0.1:15201 监听 + CONNECT magic 地址)
ss -tlnp | grep 15201 # => uot_relay.py 监听 127.0.0.1:15201 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('<代理用户名>:<代理密码>')) # nginx CONNECT(带 Basic Auth,B64 = base64('<代理用户名>:<代理密码>'))
B64=$(printf '<代理用户名>:<代理密码>' | base64 -w0) B64=$(printf '<代理用户名>:<代理密码>' | base64 -w0)
curl -sk --proxy-insecure -x https://<节点域名>:<nginx端口> \ curl -sk --proxy-insecure -x https://<节点域名>:<nginx端口> \
@@ -532,6 +764,9 @@ curl -sk https://<节点域名>:<nginx端口>/ -o /dev/null -w '%{http_code}\n'
``` ```
server_config/ server_config/
├── README.md ← 本文档 ├── 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/
│ ├── nginx.conf ← 完整 nginx 配置(占位符版) │ ├── nginx.conf ← 完整 nginx 配置(占位符版)
│ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改) │ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改)
@@ -544,13 +779,81 @@ server_config/
├── systemd/ ├── systemd/
│ ├── nginx.service │ ├── nginx.service
│ ├── sing-box.service │ ├── sing-box.service
│ └── mtg.service │ ├── mtg.service
│ └── rrpp-qdisc.service ← 开机把默认网卡 qdisc 设为 fq(见 §2.6)
├── scripts/ ├── scripts/
│ └── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护 │ ├── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护
│ └── rrpp-qdisc.sh ← fq 队列设置脚本(rrpp-qdisc.service 调用)
├── cron/ ├── cron/
│ └── sync-nginx-local-ips ← cron 定义(每 5 分钟) │ └── sync-nginx-local-ips ← cron 定义(每 5 分钟)
├── rrpp-subscription/ ← RRPP 订阅后端(bj,见 §12)
│ ├── API.md ← 订阅端点 / method / 分层 / 规则集文档
│ └── gen_rulesets.py ← PAC → 客户端规则集转换脚本
└── fake-site/ └── fake-site/
└── index.html ← 伪装站点页面(所有节点共用同一份) └── index.html ← 伪装站点页面(所有节点共用同一份)
``` ```
> 旧的 `traffic-monitor/`、`pac/`、`conf.d/*.conf` 已下线或迁移,不再维护,已从本目录移除。 > 旧的 `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 兼容说明实现)
+152
View File
@@ -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())
+18
View File
@@ -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
+12
View File
@@ -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
+118 -25
View File
@@ -13,18 +13,29 @@ RRPP UoT v2 relay — UDP over TCP 解封装服务(sing-box UoT v2 协议)
non-connect 帧: | ATYP | address | port | length u16be | data | non-connect 帧: | ATYP | address | port | length u16be | data |
监听 127.0.0.1,仅供本机 nginx proxy_connect 转发,不对公网开放。 监听 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 socket
import struct import struct
import threading import threading
import select
import time import time
import sys
LISTEN_HOST = "127.0.0.1" LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = 15201 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 单包上限 MAX_DGRAM = 65507 # UDP over IPv4 单包上限
SEND_RETRY_TOTAL = 1.0 # EAGAIN 时最多等待 1 秒可写,超时则丢弃该包
RECV_ERROR_STREAK_LIMIT = 200 # 连续 recv 错误上限(防病态自旋),超过视为隧道异常
LOG_PREFIX = "[uot-relay]" LOG_PREFIX = "[uot-relay]"
@@ -74,21 +85,76 @@ def encode_addr(addr, port):
return b"\x02" + bytes([len(d)]) + d + struct.pack(">H", 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): def handle(conn, peer):
conn.settimeout(IDLE_TIMEOUT) conn.settimeout(IDLE_TIMEOUT)
f = conn.makefile("rb") f = conn.makefile("rb")
udp = None pool = UdpPool()
stop = threading.Event() stop = threading.Event()
fixed_dst = None
try: try:
is_connect = read_exact(f, 1)[0] is_connect = read_exact(f, 1)[0]
req_dst = parse_addr(f) req_dst = parse_addr(f)
log(f"conn from nginx: isConnect={is_connect} dst={req_dst}") 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: if is_connect:
udp.connect(req_dst) # 固定目标:连通式 UDP fixed_dst = req_dst
pool.get(fixed_dst[0]) # 预建 socket:主循环首轮才有可 select 的对象
write_lock = threading.Lock() write_lock = threading.Lock()
@@ -97,7 +163,7 @@ def handle(conn, peer):
conn.sendall(data) conn.sendall(data)
def uplink(): def uplink():
"""TCP -> UDP:按格式读帧并发送。""" """TCP -> UDP:逐帧解析并发送;单包错误只丢包,不关隧道。"""
try: try:
while not stop.is_set(): while not stop.is_set():
if is_connect: if is_connect:
@@ -105,34 +171,65 @@ def handle(conn, peer):
if ln > MAX_DGRAM: if ln > MAX_DGRAM:
raise ValueError(f"datagram too large: {ln}") raise ValueError(f"datagram too large: {ln}")
data = read_exact(f, ln) data = read_exact(f, ln)
udp.send(data) send_dgram(pool.get(fixed_dst[0]), data, fixed_dst)
else: else:
dst = parse_addr(f) dst = parse_addr(f)
ln = struct.unpack(">H", read_exact(f, 2))[0] ln = struct.unpack(">H", read_exact(f, 2))[0]
if ln > MAX_DGRAM: if ln > MAX_DGRAM:
raise ValueError(f"datagram too large: {ln}") raise ValueError(f"datagram too large: {ln}")
data = read_exact(f, ln) data = read_exact(f, ln)
udp.sendto(data, dst) send_dgram(pool.get(dst[0]), data, dst)
except Exception as e: except Exception as e:
if not stop.is_set(): if not stop.is_set():
log(f"uplink end: {type(e).__name__}: {e}") log(f"uplink end: {type(e).__name__}: {e}")
finally: finally:
stop.set() stop.set()
t = threading.Thread(target=uplink, daemon=True) threading.Thread(target=uplink, daemon=True).start()
t.start()
# 主循环:UDP -> TCP,带空闲超时 # 主循环:UDP -> TCP(带空闲超时)
last = time.time() last = time.time()
err_streak = 0
while not stop.is_set(): while not stop.is_set():
r, _, _ = select.select([udp], [], [], 2.0) socks = pool.all()
if r: 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: if is_connect:
data = udp.recv(MAX_DGRAM) if (a, p) != fixed_dst:
continue # 固定目标模式:只回传目标自身的包
tcp_write(struct.pack(">H", len(data)) + data) tcp_write(struct.pack(">H", len(data)) + data)
else: else:
data, src = udp.recvfrom(MAX_DGRAM) tcp_write(encode_addr(a, p) + struct.pack(">H", len(data)) + data)
tcp_write(encode_addr(*src) + struct.pack(">H", len(data)) + data) got = True
if got:
last = time.time() last = time.time()
elif time.time() - last > IDLE_TIMEOUT: elif time.time() - last > IDLE_TIMEOUT:
log("idle timeout, closing") log("idle timeout, closing")
@@ -149,11 +246,7 @@ def handle(conn, peer):
conn.close() conn.close()
except Exception: except Exception:
pass pass
if udp: pool.close()
try:
udp.close()
except Exception:
pass
def main(): def main():