feat(lite): merge TUN mode with IPv6 routing
This commit is contained in:
@@ -265,6 +265,29 @@ chmod 644 /etc/nginx/.htpasswd # worker 以 nobody 运行,必须可读
|
||||
1. `map` 判定私网/回环段 + cron 维护的本机公网 IP 规则 → 直连伪装页
|
||||
2. `/__proxy` 的 Loop-Guard 头兜底 → 二次进入返回 508
|
||||
|
||||
### 5.5 路线 B 裸 UoT relay
|
||||
|
||||
TUN 客户端的 UDP 会先通过认证 CONNECT 请求
|
||||
`sp.v2.udp-over-tcp.arpa:443`。当前 hk2 使用的
|
||||
`ngx_http_proxy_connect_module` 没有注册 `$connect_host`,因此在 fallback
|
||||
server 实际接收的 CONNECT 请求上用 `$request` 正则匹配,再把连接转到
|
||||
`127.0.0.1:15201`;nginx 只负责 TLS、Basic Auth 和字节流转发,不解析 UoT。relay 使用仓库中的
|
||||
`[uot_relay.py](uot_relay.py)`,只监听回环地址,不需要 sing-box:
|
||||
|
||||
```bash
|
||||
install -m 755 uot_relay.py /opt/rrpp-uot-relay/uot_relay.py
|
||||
install -m 644 systemd/uot-relay.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now uot-relay
|
||||
ss -lntp | grep 127.0.0.1:15201
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
客户端的 CONNECT 端口必须大于 0,当前统一使用 `443`;`15201` 是服务器回环
|
||||
relay 的内部端口,不写入客户端 CONNECT 目标。nginx fallback 块使用
|
||||
`proxy_connect_read_timeout 10m` 和 `proxy_connect_send_timeout 10m`,不要再同时
|
||||
配置会产生 duplicate 的 `proxy_connect_data_timeout`。
|
||||
|
||||
---
|
||||
|
||||
### 5.5 UoT(UDP over TCP)支持
|
||||
|
||||
@@ -16,18 +16,12 @@ 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)。
|
||||
# UoT v2 magic CONNECT 必须在 fallback server 中命中;当前模块版本没有
|
||||
# $connect_host,使用原始请求行匹配。客户端请求 magic-host:443,nginx
|
||||
# 将字节流转到本机裸 relay 127.0.0.1:15201。
|
||||
map $request $rrpp_uot_connect_address {
|
||||
default $connect_addr;
|
||||
"~^CONNECT sp\.v2\.udp-over-tcp\.arpa" 127.0.0.1:15201;
|
||||
default $connect_addr;
|
||||
"~^CONNECT[[:space:]]+sp\.v2\.udp-over-tcp\.arpa" 127.0.0.1:15201;
|
||||
}
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=RRPP bare UDP over TCP v2 relay
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
User=rrpp
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/rrpp
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=RRPP bare UDP over TCP v2 relay
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
User=rrpp
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/rrpp
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""RRPP 裸 UoT v2 relay。
|
||||
|
||||
nginx 的 proxy_connect 模块负责 TLS、Basic Auth 和 CONNECT,relay 只监听
|
||||
127.0.0.1:15201,收到的首字节就是 UoT v2 request,不再解析 HTTP。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
|
||||
MAGIC = "sp.v2.udp-over-tcp.arpa"
|
||||
LISTEN_HOST = "127.0.0.1"
|
||||
LISTEN_PORT = 15201
|
||||
MAX_PACKET = 65535
|
||||
IDLE_TIMEOUT = 9 * 60
|
||||
|
||||
|
||||
def read_exact(conn: socket.socket, size: int) -> bytes:
|
||||
data = bytearray()
|
||||
while len(data) < size:
|
||||
chunk = conn.recv(size - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("客户端连接已关闭")
|
||||
data.extend(chunk)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def read_address(conn: socket.socket):
|
||||
atyp = read_exact(conn, 1)[0]
|
||||
if atyp == 0:
|
||||
host = str(ipaddress.IPv4Address(read_exact(conn, 4)))
|
||||
elif atyp == 1:
|
||||
host = str(ipaddress.IPv6Address(read_exact(conn, 16)))
|
||||
elif atyp == 2:
|
||||
size = read_exact(conn, 1)[0]
|
||||
host = read_exact(conn, size).decode("idna")
|
||||
else:
|
||||
raise ValueError(f"未知 UoT 地址类型: {atyp}")
|
||||
return host, struct.unpack("!H", read_exact(conn, 2))[0]
|
||||
|
||||
|
||||
def encode_address(host: str, port: int) -> bytes:
|
||||
try:
|
||||
addr = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
raw = host.encode("idna")
|
||||
return b"\x02" + bytes([len(raw)]) + raw + struct.pack("!H", port)
|
||||
return (b"\x00" if addr.version == 4 else b"\x01") + addr.packed + struct.pack("!H", port)
|
||||
|
||||
|
||||
def resolve_destination(host: str, port: int):
|
||||
return socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_DGRAM)[0][4]
|
||||
|
||||
|
||||
def handle(conn: socket.socket, peer):
|
||||
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
udp.settimeout(0.2)
|
||||
# 与 nginx 的 10 分钟读写超时错开,空闲连接由 relay 先回收。
|
||||
conn.settimeout(IDLE_TIMEOUT)
|
||||
send_lock = threading.Lock()
|
||||
try:
|
||||
is_connect = read_exact(conn, 1)[0]
|
||||
first_target = read_address(conn)
|
||||
logging.info("UoT client %s target=%s:%s connect=%s", peer, *first_target, is_connect)
|
||||
|
||||
def udp_reader():
|
||||
while True:
|
||||
try:
|
||||
data, source = udp.recvfrom(MAX_PACKET)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
with send_lock:
|
||||
if is_connect:
|
||||
conn.sendall(struct.pack("!H", len(data)) + data)
|
||||
else:
|
||||
conn.sendall(encode_address(source[0], source[1]) +
|
||||
struct.pack("!H", len(data)) + data)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
threading.Thread(target=udp_reader, daemon=True).start()
|
||||
if is_connect:
|
||||
while True:
|
||||
length = struct.unpack("!H", read_exact(conn, 2))[0]
|
||||
payload = read_exact(conn, length)
|
||||
udp.sendto(payload, resolve_destination(*first_target))
|
||||
else:
|
||||
while True:
|
||||
destination = read_address(conn)
|
||||
length = struct.unpack("!H", read_exact(conn, 2))[0]
|
||||
payload = read_exact(conn, length)
|
||||
udp.sendto(payload, resolve_destination(*destination))
|
||||
except (ConnectionError, OSError, ValueError) as exc:
|
||||
logging.info("UoT client %s closed: %s", peer, exc)
|
||||
finally:
|
||||
udp.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
listener.bind((LISTEN_HOST, LISTEN_PORT))
|
||||
listener.listen(128)
|
||||
logging.info("RRPP UoT relay listening on %s:%d", LISTEN_HOST, LISTEN_PORT)
|
||||
while True:
|
||||
conn, peer = listener.accept()
|
||||
threading.Thread(target=handle, args=(conn, peer), daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user