diff --git a/server_config/README.md b/server_config/README.md index aed4893..c869ce3 100644 --- a/server_config/README.md +++ b/server_config/README.md @@ -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: <上面生成的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(('<节点域名>',)) 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://<节点域名>: \ @@ -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/ diff --git a/server_config/nginx/nginx.conf b/server_config/nginx/nginx.conf index 65b636d..75c123c 100644 --- a/server_config/nginx/nginx.conf +++ b/server_config/nginx/nginx.conf @@ -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; diff --git a/server_config/uot-relay/uot-relay.service b/server_config/uot-relay/uot-relay.service new file mode 100644 index 0000000..cf3babc --- /dev/null +++ b/server_config/uot-relay/uot-relay.service @@ -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 diff --git a/server_config/uot-relay/uot_relay.py b/server_config/uot-relay/uot_relay.py new file mode 100644 index 0000000..de6b559 --- /dev/null +++ b/server_config/uot-relay/uot_relay.py @@ -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()