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:
@@ -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. sing-box(VLESS+REALITY / Shadowsocks)
|
||||||
|
|
||||||
### 6.1 安装
|
### 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) |
|
| nginx | [systemd/nginx.service](systemd/nginx.service) |
|
||||||
| 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) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp systemd/*.service /etc/systemd/system/
|
cp systemd/*.service /etc/systemd/system/
|
||||||
@@ -396,6 +440,7 @@ sysctl --system # 1. BBR
|
|||||||
systemctl start sing-box # 2. VLESS/SS
|
systemctl start sing-box # 2. VLESS/SS
|
||||||
systemctl start mtg # 3. MTProto
|
systemctl start mtg # 3. MTProto
|
||||||
systemctl start nginx # 4. nginx(最后,依赖证书/伪装页/规则文件)
|
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))
|
s.sendall(b'\x16\x03\x01'+struct.pack('>H',100)+os.urandom(100))
|
||||||
print('resp:',s.recv(8).hex())" # 期望首字节 15 (TLS alert)
|
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('<代理用户名>:<代理密码>'))
|
# 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端口> \
|
||||||
@@ -440,6 +488,9 @@ server_config/
|
|||||||
│ ├── nginx.conf ← 完整 nginx 配置(占位符版)
|
│ ├── nginx.conf ← 完整 nginx 配置(占位符版)
|
||||||
│ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改)
|
│ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改)
|
||||||
│ └── conf.d/ ← 预留:额外站点配置
|
│ └── conf.d/ ← 预留:额外站点配置
|
||||||
|
├── uot-relay/
|
||||||
|
│ ├── uot_relay.py ← UoT v2 relay(UDP over TCP 解封装)
|
||||||
|
│ └── uot-relay.service ← systemd unit
|
||||||
├── sing-box/
|
├── sing-box/
|
||||||
│ └── config.json ← sing-box 配置(VLESS+REALITY + SS,占位符版)
|
│ └── config.json ← sing-box 配置(VLESS+REALITY + SS,占位符版)
|
||||||
├── systemd/
|
├── systemd/
|
||||||
|
|||||||
@@ -16,6 +16,19 @@ http {
|
|||||||
default 0;
|
default 0;
|
||||||
"~^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)" 1;
|
"~^(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;
|
include mime.types;
|
||||||
default_type application/octet-stream;
|
default_type application/octet-stream;
|
||||||
sendfile on;
|
sendfile on;
|
||||||
@@ -44,9 +57,12 @@ http {
|
|||||||
|
|
||||||
proxy_connect;
|
proxy_connect;
|
||||||
proxy_connect_allow all;
|
proxy_connect_allow all;
|
||||||
|
proxy_connect_address $rrpp_uot_connect_address;
|
||||||
proxy_connect_connect_timeout 60s;
|
proxy_connect_connect_timeout 60s;
|
||||||
proxy_connect_read_timeout 120s;
|
# 注:此模块版本 proxy_connect_data_timeout 与 proxy_connect_read_timeout
|
||||||
proxy_connect_send_timeout 120s;
|
# 写同一字段(同时配置报 duplicate),长连接超时用 read/send timeout 设置
|
||||||
|
proxy_connect_read_timeout 10m;
|
||||||
|
proxy_connect_send_timeout 10m;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
auth_basic off;
|
auth_basic off;
|
||||||
@@ -92,9 +108,12 @@ http {
|
|||||||
|
|
||||||
proxy_connect;
|
proxy_connect;
|
||||||
proxy_connect_allow all;
|
proxy_connect_allow all;
|
||||||
|
proxy_connect_address $rrpp_uot_connect_address;
|
||||||
proxy_connect_connect_timeout 60s;
|
proxy_connect_connect_timeout 60s;
|
||||||
proxy_connect_read_timeout 120s;
|
# 注:此模块版本 proxy_connect_data_timeout 与 proxy_connect_read_timeout
|
||||||
proxy_connect_send_timeout 120s;
|
# 写同一字段(同时配置报 duplicate),长连接超时用 read/send timeout 设置
|
||||||
|
proxy_connect_read_timeout 10m;
|
||||||
|
proxy_connect_send_timeout 10m;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
auth_basic off;
|
auth_basic off;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user