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:
@@ -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