server_config: 新增 UoT v2(UDP over TCP)支持

全节点(jp/us/ru/aq/hk2)已部署并实测通过(UoT DNS 端到端 ✅ + TCP 回归 ✅):

- nginx 模板:map $request 识别 sing-box UoT v2 magic 地址
  (sp.v2.udp-over-tcp.arpa)→ 127.0.0.1:15201;default_server 块加
  proxy_connect_address;read/send timeout 10m
- 新增 uot-relay/:Python 标准库 UoT v2 relay(connect/non-connect 双模式,
  仅监听 127.0.0.1,空闲 9 分钟断开)
- README 5.5 节:部署方法 + 客户端协议 + 四个实测坑
  ($connect_host 未注册变量 / data_timeout 与 read_timeout 同槽 /
  CONNECT 恒落 default_server / 拒绝端口 0)
This commit is contained in:
RRRosmontis
2026-09-19 00:31:41 +08:00
parent e16ef58182
commit 2e56f82e7b
4 changed files with 259 additions and 4 deletions
+51
View File
@@ -267,6 +267,49 @@ chmod 644 /etc/nginx/.htpasswd # worker 以 nobody 运行,必须可读
---
### 5.5 UoT(UDP over TCP)支持
HTTP CONNECT 只隧道 TCP。要让 UDP 应用(DNS/游戏/QUIC 等)走代理,需 **UoT v2**
(SagerNet 专有协议,见 sing-box 文档 `configuration/shared/udp-over-tcp`):
客户端把 UDP 数据报封装进 TCP 流,经 CONNECT 隧道发到 magic 地址
`sp.v2.udp-over-tcp.arpa`,由本机 **UoT relay** 解封装后转发真实 UDP 目标。
```
客户端 TUN/UDP ──UoT v2──▶ TLS HTTP CONNECT ──▶ nginx proxy_connect
──▶ 127.0.0.1:15201(uot-relay)──▶ 真实 UDP 目标
```
**relay 部署**(Python 标准库实现,见 [uot-relay/uot_relay.py](uot-relay/uot_relay.py)):
```bash
install -d /opt/rrpp-uot-relay
install -m 755 uot-relay/uot_relay.py /opt/rrpp-uot-relay/uot_relay.py
install -m 644 uot-relay/uot-relay.service /etc/systemd/system/uot-relay.service
systemctl daemon-reload && systemctl enable --now uot-relay
```
relay 只监听 `127.0.0.1:15201`,空闲 9 分钟断开(须小于 nginx 隧道超时 10m)。
支持 UoT v2 两种模式:`isConnect=1` 固定目标、`isConnect=0` 每帧自带目标。
**nginx 侧**(模板已含,[nginx/nginx.conf](nginx/nginx.conf)):`map $request` 识别
magic 地址 → `127.0.0.1:15201`,其余 CONNECT 走 `$connect_addr` 默认解析;
`proxy_connect_address $rrpp_uot_connect_address;` 放在 **default_server 块**。
**四个实测坑(2026-09 全节点部署验证)**:
1. 该模块版本**未注册 `$connect_host` 变量**(README 有、源码无)→ 必须用
`$request` 正则匹配(任何阶段可用);
2. `proxy_connect_data_timeout` 与 `proxy_connect_read_timeout` **写同一配置槽**,
同时写报 duplicate → 长连接超时用 read/send timeout 设 10m;
3. **所有 CONNECT 请求都落在 `default_server`(fallback)块**——nginx 按请求
Host 头选虚拟主机,CONNECT 的 Host 是目标域名,永远匹配不到 `server_name`;
4. 模块**拒绝 CONNECT 端口 0**(`port < 1` 返回 400)→ 客户端连 magic 地址时
端口必须非 0(用 443 即可,relay 不读 CONNECT 端口,目标取自 UoT 帧头)。
**客户端协议**:认证与 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 |`,
connect 模式帧 `| length u16be | data |`,non-connect 帧每帧自带目标地址。
## 6. sing-box(VLESS+REALITY / Shadowsocks)
### 6.1 安装
@@ -378,6 +421,7 @@ mtg simple-run --prefer-ip=prefer-ipv4 0.0.0.0:<mtproto端口> <上面生成的s
| nginx | [systemd/nginx.service](systemd/nginx.service) |
| 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) |
```bash
cp systemd/*.service /etc/systemd/system/
@@ -396,6 +440,7 @@ sysctl --system # 1. BBR
systemctl start sing-box # 2. VLESS/SS
systemctl start mtg # 3. MTProto
systemctl start nginx # 4. nginx(最后,依赖证书/伪装页/规则文件)
systemctl start uot-relay # 5. UoT relay(须在 nginx 前可用)
```
### 验证清单
@@ -419,6 +464,9 @@ s=socket.connect(('<节点域名>',<mtproto端口>)) if False else socket.create
s.sendall(b'\x16\x03\x01'+struct.pack('>H',100)+os.urandom(100))
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
# nginx CONNECT(带 Basic Auth,B64 = base64('<代理用户名>:<代理密码>'))
B64=$(printf '<代理用户名>:<代理密码>' | base64 -w0)
curl -sk --proxy-insecure -x https://<节点域名>:<nginx端口> \
@@ -440,6 +488,9 @@ server_config/
│ ├── nginx.conf ← 完整 nginx 配置(占位符版)
│ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改)
│ └── conf.d/ ← 预留:额外站点配置
├── uot-relay/
│ ├── uot_relay.py ← UoT v2 relay(UDP over TCP 解封装)
│ └── uot-relay.service ← systemd unit
├── sing-box/
│ └── config.json ← sing-box 配置(VLESS+REALITY + SS,占位符版)
├── systemd/
+23 -4
View File
@@ -16,6 +16,19 @@ 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)。
map $request $rrpp_uot_connect_address {
default $connect_addr;
"~^CONNECT sp\.v2\.udp-over-tcp\.arpa" 127.0.0.1:15201;
}
include mime.types;
default_type application/octet-stream;
sendfile on;
@@ -44,9 +57,12 @@ http {
proxy_connect;
proxy_connect_allow all;
proxy_connect_address $rrpp_uot_connect_address;
proxy_connect_connect_timeout 60s;
proxy_connect_read_timeout 120s;
proxy_connect_send_timeout 120s;
# 注:此模块版本 proxy_connect_data_timeout 与 proxy_connect_read_timeout
# 写同一字段(同时配置报 duplicate),长连接超时用 read/send timeout 设置
proxy_connect_read_timeout 10m;
proxy_connect_send_timeout 10m;
location / {
auth_basic off;
@@ -92,9 +108,12 @@ http {
proxy_connect;
proxy_connect_allow all;
proxy_connect_address $rrpp_uot_connect_address;
proxy_connect_connect_timeout 60s;
proxy_connect_read_timeout 120s;
proxy_connect_send_timeout 120s;
# 注:此模块版本 proxy_connect_data_timeout 与 proxy_connect_read_timeout
# 写同一字段(同时配置报 duplicate),长连接超时用 read/send timeout 设置
proxy_connect_read_timeout 10m;
proxy_connect_send_timeout 10m;
location / {
auth_basic off;
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=RRPP UoT v2 relay (UDP over TCP decapsulator, localhost only)
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
Restart=on-failure
RestartSec=5
# 双保险:只许监听本机(脚本本身 bind 127.0.0.1)
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RRPP UoT v2 relay — UDP over TCP 解封装服务(sing-box UoT v2 协议)
协议(SagerNet 专有,见 sing-box 文档 configuration/shared/udp-over-tcp):
请求头: | isConnect u8 | ATYP u8 | address | port u16be |
isConnect=1: 固定目标模式,后续帧全部是 length+data,发往请求头里的目标
isConnect=0: 每帧自带目标,帧格式同 v1: | ATYP | address | port | length u16be | data |
connect 帧: | length u16be | data |
non-connect 帧: | ATYP | address | port | length u16be | data |
监听 127.0.0.1,仅供本机 nginx proxy_connect 转发,不对公网开放。
"""
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)
MAX_DGRAM = 65507 # UDP over IPv4 单包上限
LOG_PREFIX = "[uot-relay]"
def log(msg):
print(f"{LOG_PREFIX} {time.strftime('%Y-%m-%d %H:%M:%S')} {msg}", flush=True)
def read_exact(f, n):
buf = b""
while len(buf) < n:
chunk = f.read(n - len(buf))
if not chunk:
raise EOFError("connection closed")
buf += chunk
return buf
def parse_addr(f):
"""从流中读取 SOCKS 格式地址(ATYP + address + port)。"""
atyp = read_exact(f, 1)[0]
if atyp == 0x00:
addr = socket.inet_ntop(socket.AF_INET, read_exact(f, 4))
elif atyp == 0x01:
addr = socket.inet_ntop(socket.AF_INET6, read_exact(f, 16))
elif atyp == 0x02:
ln = read_exact(f, 1)[0]
addr = read_exact(f, ln).decode("utf-8", "replace")
else:
raise ValueError(f"bad ATYP {atyp:#x}")
port = struct.unpack(">H", read_exact(f, 2))[0]
return addr, port
def encode_addr(addr, port):
"""SOCKS 格式地址编码(用于 non-connect 模式的回包)。"""
try:
return b"\x00" + socket.inet_pton(socket.AF_INET, addr) + struct.pack(">H", port)
except OSError:
pass
try:
return b"\x01" + socket.inet_pton(socket.AF_INET6, addr) + struct.pack(">H", port)
except OSError:
pass
d = addr.encode("utf-8")
if len(d) > 255:
raise ValueError("domain too long")
return b"\x02" + bytes([len(d)]) + d + struct.pack(">H", port)
def handle(conn, peer):
conn.settimeout(IDLE_TIMEOUT)
f = conn.makefile("rb")
udp = None
stop = threading.Event()
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
write_lock = threading.Lock()
def tcp_write(data):
with write_lock:
conn.sendall(data)
def uplink():
"""TCP -> UDP:按格式读帧并发送。"""
try:
while not stop.is_set():
if is_connect:
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.send(data)
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)
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()
# 主循环:UDP -> TCP,带空闲超时
last = time.time()
while not stop.is_set():
r, _, _ = select.select([udp], [], [], 2.0)
if r:
if is_connect:
data = udp.recv(MAX_DGRAM)
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)
last = time.time()
elif time.time() - last > IDLE_TIMEOUT:
log("idle timeout, closing")
break
except Exception as e:
log(f"conn error: {type(e).__name__}: {e}")
finally:
stop.set()
try:
f.close()
except Exception:
pass
try:
conn.close()
except Exception:
pass
if udp:
try:
udp.close()
except Exception:
pass
def main():
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((LISTEN_HOST, LISTEN_PORT))
srv.listen(128)
log(f"listening on {LISTEN_HOST}:{LISTEN_PORT}")
while True:
conn, peer = srv.accept()
threading.Thread(target=handle, args=(conn, peer), daemon=True).start()
if __name__ == "__main__":
main()