feat(lite): merge TUN mode with IPv6 routing

This commit is contained in:
RoyirinRosmontis
2026-09-19 03:41:41 +08:00
parent 3e29012d9c
commit 50364dc17a
23 changed files with 2996 additions and 237 deletions
+23
View File
@@ -265,6 +265,29 @@ chmod 644 /etc/nginx/.htpasswd # worker 以 nobody 运行,必须可读
1. `map` 判定私网/回环段 + cron 维护的本机公网 IP 规则 → 直连伪装页
2. `/__proxy` 的 Loop-Guard 头兜底 → 二次进入返回 508
### 5.5 路线 B 裸 UoT relay
TUN 客户端的 UDP 会先通过认证 CONNECT 请求
`sp.v2.udp-over-tcp.arpa:443`。当前 hk2 使用的
`ngx_http_proxy_connect_module` 没有注册 `$connect_host`,因此在 fallback
server 实际接收的 CONNECT 请求上用 `$request` 正则匹配,再把连接转到
`127.0.0.1:15201`;nginx 只负责 TLS、Basic Auth 和字节流转发,不解析 UoT。relay 使用仓库中的
`[uot_relay.py](uot_relay.py)`,只监听回环地址,不需要 sing-box:
```bash
install -m 755 uot_relay.py /opt/rrpp-uot-relay/uot_relay.py
install -m 644 systemd/uot-relay.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now uot-relay
ss -lntp | grep 127.0.0.1:15201
nginx -t && systemctl reload nginx
```
客户端的 CONNECT 端口必须大于 0,当前统一使用 `443`;`15201` 是服务器回环
relay 的内部端口,不写入客户端 CONNECT 目标。nginx fallback 块使用
`proxy_connect_read_timeout 10m` 和 `proxy_connect_send_timeout 10m`,不要再同时
配置会产生 duplicate 的 `proxy_connect_data_timeout`。
---
### 5.5 UoT(UDP over TCP)支持
+5 -11
View File
@@ -16,18 +16,12 @@ http {
default 0;
"~^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)" 1;
}
# UoT v2(UDP over TCP):CONNECT 目标为 sing-box UoT v2 magic 地址
# (sp.v2.udp-over-tcp.arpa)时转发到本机 UoT relay(127.0.0.1:15201),
# 其余 CONNECT 走默认解析($connect_addr)。
# 注意(实测结论,勿踩坑):
# 1. 必须用 $request 匹配——此模块版本未注册 $connect_host 变量;
# 2. proxy_connect_address 必须放在 default_server 块——nginx 按 Host 头
# 选虚拟主机,CONNECT 的 Host 是目标域名,永远落在 default_server;
# 3. 客户端 CONNECT magic 地址时端口必须非 0(模块拒绝 port<1,返回 400)。
# UoT v2 magic CONNECT 必须在 fallback server 中命中;当前模块版本没有
# $connect_host,使用原始请求行匹配。客户端请求 magic-host:443,nginx
# 将字节流转到本机裸 relay 127.0.0.1:15201。
map $request $rrpp_uot_connect_address {
default $connect_addr;
"~^CONNECT sp\.v2\.udp-over-tcp\.arpa" 127.0.0.1:15201;
default $connect_addr;
"~^CONNECT[[:space:]]+sp\.v2\.udp-over-tcp\.arpa" 127.0.0.1:15201;
}
include mime.types;
default_type application/octet-stream;
@@ -0,0 +1,19 @@
[Unit]
Description=RRPP bare UDP over TCP v2 relay
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
Restart=always
RestartSec=2
User=rrpp
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/rrpp
[Install]
WantedBy=multi-user.target
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=RRPP bare UDP over TCP v2 relay
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
Restart=always
RestartSec=2
User=rrpp
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/rrpp
[Install]
WantedBy=multi-user.target
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""RRPP 裸 UoT v2 relay。
nginx 的 proxy_connect 模块负责 TLS、Basic Auth 和 CONNECT,relay 只监听
127.0.0.1:15201,收到的首字节就是 UoT v2 request,不再解析 HTTP。
"""
from __future__ import annotations
import ipaddress
import logging
import socket
import struct
import threading
MAGIC = "sp.v2.udp-over-tcp.arpa"
LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = 15201
MAX_PACKET = 65535
IDLE_TIMEOUT = 9 * 60
def read_exact(conn: socket.socket, size: int) -> bytes:
data = bytearray()
while len(data) < size:
chunk = conn.recv(size - len(data))
if not chunk:
raise ConnectionError("客户端连接已关闭")
data.extend(chunk)
return bytes(data)
def read_address(conn: socket.socket):
atyp = read_exact(conn, 1)[0]
if atyp == 0:
host = str(ipaddress.IPv4Address(read_exact(conn, 4)))
elif atyp == 1:
host = str(ipaddress.IPv6Address(read_exact(conn, 16)))
elif atyp == 2:
size = read_exact(conn, 1)[0]
host = read_exact(conn, size).decode("idna")
else:
raise ValueError(f"未知 UoT 地址类型: {atyp}")
return host, struct.unpack("!H", read_exact(conn, 2))[0]
def encode_address(host: str, port: int) -> bytes:
try:
addr = ipaddress.ip_address(host)
except ValueError:
raw = host.encode("idna")
return b"\x02" + bytes([len(raw)]) + raw + struct.pack("!H", port)
return (b"\x00" if addr.version == 4 else b"\x01") + addr.packed + struct.pack("!H", port)
def resolve_destination(host: str, port: int):
return socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_DGRAM)[0][4]
def handle(conn: socket.socket, peer):
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp.settimeout(0.2)
# 与 nginx 的 10 分钟读写超时错开,空闲连接由 relay 先回收。
conn.settimeout(IDLE_TIMEOUT)
send_lock = threading.Lock()
try:
is_connect = read_exact(conn, 1)[0]
first_target = read_address(conn)
logging.info("UoT client %s target=%s:%s connect=%s", peer, *first_target, is_connect)
def udp_reader():
while True:
try:
data, source = udp.recvfrom(MAX_PACKET)
except socket.timeout:
continue
except OSError:
return
try:
with send_lock:
if is_connect:
conn.sendall(struct.pack("!H", len(data)) + data)
else:
conn.sendall(encode_address(source[0], source[1]) +
struct.pack("!H", len(data)) + data)
except OSError:
return
threading.Thread(target=udp_reader, daemon=True).start()
if is_connect:
while True:
length = struct.unpack("!H", read_exact(conn, 2))[0]
payload = read_exact(conn, length)
udp.sendto(payload, resolve_destination(*first_target))
else:
while True:
destination = read_address(conn)
length = struct.unpack("!H", read_exact(conn, 2))[0]
payload = read_exact(conn, length)
udp.sendto(payload, resolve_destination(*destination))
except (ConnectionError, OSError, ValueError) as exc:
logging.info("UoT client %s closed: %s", peer, exc)
finally:
udp.close()
conn.close()
def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind((LISTEN_HOST, LISTEN_PORT))
listener.listen(128)
logging.info("RRPP UoT relay listening on %s:%d", LISTEN_HOST, LISTEN_PORT)
while True:
conn, peer = listener.accept()
threading.Thread(target=handle, args=(conn, peer), daemon=True).start()
if __name__ == "__main__":
main()