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 问题。
This commit is contained in:
RRRosmontis
2026-09-19 16:41:19 +08:00
parent 41204df78a
commit bdd9d3498d
2 changed files with 133 additions and 25 deletions
+15
View File
@@ -328,6 +328,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 |`,
+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():