ae8334140d
问题: 协程被取消/销毁时,finally 块中的异步操作触发 GeneratorExit 原因: GeneratorExit 是 BaseException 子类,不会被 Exception 捕获 修复内容: - server.py handle(): 捕获 CancelledError + GeneratorExit - server.py _tunnel(): 被取消时不执行 _record_stats() 异步操作 - instances.py _accept_connection(): 添加异常处理和 writer 关闭 - 所有取消场景都静默处理(不抛异常/不记录错误日志)
476 lines
17 KiB
Python
476 lines
17 KiB
Python
"""SOCKS5 异步服务器引擎 (asyncio)。"""
|
||
import asyncio
|
||
import logging
|
||
import struct
|
||
import time
|
||
import uuid
|
||
import ipaddress
|
||
from datetime import datetime, timezone
|
||
|
||
import models
|
||
from services.user_service import UserService
|
||
|
||
log = logging.getLogger("socks.engine")
|
||
|
||
# ── SOCKS5 协议常量 ─────────────────────────────────────────────
|
||
SOCKS_VERSION = 5
|
||
METHOD_NO_AUTH = 0x00
|
||
METHOD_USERPASS = 0x02
|
||
METHOD_NOT_ACCEPTED = 0xFF
|
||
|
||
CMD_CONNECT = 0x01
|
||
CMD_BIND = 0x02
|
||
CMD_UDP_ASSOCIATE = 0x03
|
||
|
||
ATYP_IPV4 = 0x01
|
||
ATYP_DOMAIN = 0x03
|
||
ATYP_IPV6 = 0x04
|
||
|
||
REP_SUCCEEDED = 0x00
|
||
REP_GENERAL_FAILURE = 0x01
|
||
REP_CONN_FORBIDDEN = 0x02
|
||
REP_NETWORK_UNREACHABLE = 0x03
|
||
REP_HOST_UNREACHABLE = 0x04
|
||
REP_CONN_REFUSED = 0x05
|
||
REP_TTL_EXPIRED = 0x06
|
||
REP_CMD_NOT_SUPPORTED = 0x07
|
||
REP_ADDR_NOT_SUPPORTED = 0x08
|
||
|
||
TUNNEL_CHUNK = 65536
|
||
|
||
|
||
class ConnectionHandler:
|
||
"""单个 SOCKS5 连接的处理器。"""
|
||
|
||
def __init__(self, reader, writer, instance_config, user_service,
|
||
flask_app=None, on_close_cb=None):
|
||
self.reader = reader
|
||
self.writer = writer
|
||
self.config = instance_config
|
||
self.user_service = user_service
|
||
self.flask_app = flask_app
|
||
self.on_close = on_close_cb
|
||
|
||
self.username = None
|
||
self.src_ip = None
|
||
self.src_port = None
|
||
self.dst_addr = None
|
||
self.dst_port = None
|
||
self.conn_id = str(uuid.uuid4())[:16]
|
||
|
||
self.bytes_in = 0
|
||
self.bytes_out = 0
|
||
self.start_time = time.time()
|
||
self._closed = False
|
||
|
||
# 限速节流器
|
||
self._throttle_writer_in = None # client->dst 方向
|
||
self._throttle_writer_out = None # dst->client 方向
|
||
self._speed_semaphore_in = None
|
||
self._speed_semaphore_out = None
|
||
|
||
# 上游代理
|
||
self.upstream = None
|
||
|
||
async def handle(self):
|
||
"""主入口:处理完整的 SOCKS5 会话。"""
|
||
try:
|
||
self.src_ip, self.src_port = self._peer_info()
|
||
|
||
# 1. 方法协商
|
||
if not await self._negotiate_method():
|
||
return
|
||
|
||
# 2. 认证(如果需要)
|
||
if not await self._authenticate():
|
||
return
|
||
|
||
# 3. 解析命令请求
|
||
cmd, atyp, addr, port = await self._read_request()
|
||
if cmd is None:
|
||
return
|
||
|
||
# 4. 记录连接
|
||
await self._record_connection("active")
|
||
|
||
# 5. 处理命令(目前只支持 CONNECT)
|
||
if cmd == CMD_CONNECT:
|
||
await self._handle_connect(addr, port)
|
||
else:
|
||
await self._reply(REP_CMD_NOT_SUPPORTED)
|
||
return
|
||
|
||
except asyncio.TimeoutError:
|
||
log.debug("[%s] 超时", self.conn_id)
|
||
except ConnectionError:
|
||
log.debug("[%s] 连接错误", self.conn_id)
|
||
except (asyncio.CancelledError, GeneratorExit):
|
||
# 正常关闭,不记录错误
|
||
pass
|
||
except Exception as e:
|
||
log.exception("[%s] 异常: %s", self.conn_id, e)
|
||
finally:
|
||
if not self._closed:
|
||
self._close()
|
||
|
||
def _peer_info(self):
|
||
extra = self.writer.get_extra_info("peername")
|
||
return extra
|
||
|
||
async def _negotiate_method(self):
|
||
"""方法协商。"""
|
||
header = await asyncio.wait_for(self.reader.read(256),
|
||
timeout=self.config.timeout)
|
||
if len(header) < 2:
|
||
return False
|
||
|
||
ver, n_methods = struct.unpack("!BB", header[:2])
|
||
if ver != SOCKS_VERSION:
|
||
log.debug("协议版本错误: %d", ver)
|
||
return False
|
||
|
||
methods = header[2:2 + n_methods]
|
||
# 检查是否支持
|
||
if METHOD_USERPASS in methods and self.config.auth_method == "userpass":
|
||
self.writer.write(bytes([SOCKS_VERSION, METHOD_USERPASS]))
|
||
elif METHOD_NO_AUTH in methods:
|
||
self.writer.write(bytes([SOCKS_VERSION, METHOD_NO_AUTH]))
|
||
else:
|
||
self.writer.write(bytes([SOCKS_VERSION, METHOD_NOT_ACCEPTED]))
|
||
return False
|
||
await self.writer.drain()
|
||
return True
|
||
|
||
async def _authenticate(self):
|
||
"""USERPASS 认证。"""
|
||
if self.config.auth_method != "userpass":
|
||
return True
|
||
|
||
# RFC 1929 认证请求格式:
|
||
# +----+------+----------+------+----------+
|
||
# |VER | ULEN | UNAME | PLEN | PASSWD |
|
||
# +----+------+----------+------+----------+
|
||
# | 1 | 1 | 1 to 255 | 1 | 1 to 255 |
|
||
# +----+------+----------+------+----------+
|
||
# 注意:整个请求是一个连续 TCP 流,可能一次性全部到达
|
||
|
||
data = b""
|
||
# 1. 先读版本和用户名长度 (2 字节)
|
||
while len(data) < 2:
|
||
chunk = await asyncio.wait_for(self.reader.read(256),
|
||
timeout=self.config.timeout)
|
||
if not chunk:
|
||
return False
|
||
data += chunk
|
||
|
||
ver, ulen = struct.unpack("!BB", data[:2])
|
||
if ver != 1:
|
||
return False
|
||
|
||
offset = 2
|
||
|
||
# 2. 读取剩余需要的数据
|
||
needed = ulen + 1 # 用户名 + 密码长度字节
|
||
while len(data) < offset + needed:
|
||
chunk = await asyncio.wait_for(self.reader.read(256),
|
||
timeout=self.config.timeout)
|
||
if not chunk:
|
||
return False
|
||
data += chunk
|
||
|
||
username = data[offset:offset + ulen].decode("utf-8", errors="replace")
|
||
offset += ulen
|
||
plen = data[offset]
|
||
offset += 1
|
||
|
||
# 3. 读取密码
|
||
while len(data) < offset + plen:
|
||
chunk = await asyncio.wait_for(self.reader.read(256),
|
||
timeout=self.config.timeout)
|
||
if not chunk:
|
||
return False
|
||
data += chunk
|
||
|
||
passwd = data[offset:offset + plen].decode("utf-8", errors="replace")
|
||
|
||
# 验证(使用 bcrypt 兼容验证)
|
||
user = self.user_service.get_user(username)
|
||
if user is None or not user.check_password(passwd) or not user.is_active():
|
||
log.warning("认证失败: %s (user=%s)", self.src_ip, username)
|
||
await self.user_service.log_event("auth_fail", username,
|
||
self.src_ip, "密码错误或账号不可用")
|
||
self.writer.write(bytes([1, 0x01])) # auth fail
|
||
await self.writer.drain()
|
||
return False
|
||
|
||
self.username = username
|
||
log.info("[%s] 认证成功: %s (from %s)", self.conn_id, username, self.src_ip)
|
||
await self.user_service.log_event("auth_success", username, self.src_ip)
|
||
self.writer.write(bytes([1, 0x00])) # auth ok
|
||
await self.writer.drain()
|
||
return True
|
||
|
||
async def _read_request(self):
|
||
"""解析 SOCKS5 命令请求。"""
|
||
req = await asyncio.wait_for(self.reader.read(512),
|
||
timeout=self.config.timeout)
|
||
if len(req) < 8:
|
||
return None, None, None, None
|
||
|
||
ver, cmd, rsv, atyp = struct.unpack("!BBBB", req[:4])
|
||
if ver != SOCKS_VERSION:
|
||
return None, None, None, None
|
||
|
||
offset = 4
|
||
if atyp == ATYP_IPV4:
|
||
addr_bytes = req[offset:offset + 4]
|
||
addr = ".".join(str(b) for b in addr_bytes)
|
||
port = struct.unpack("!H", req[offset + 4:offset + 6])[0]
|
||
return cmd, atyp, addr, port
|
||
elif atyp == ATYP_IPV6:
|
||
addr_bytes = req[offset:offset + 16]
|
||
addr = str(ipaddress.IPv6Address(addr_bytes))
|
||
port = struct.unpack("!H", req[offset + 16:offset + 18])[0]
|
||
return cmd, atyp, addr, port
|
||
elif atyp == ATYP_DOMAIN:
|
||
dlen = req[offset]
|
||
addr = req[offset + 1:offset + 1 + dlen].decode("utf-8", errors="replace")
|
||
port = struct.unpack("!H", req[offset + 1 + dlen:offset + 3 + dlen])[0]
|
||
return cmd, atyp, addr, port
|
||
|
||
return None, None, None, None
|
||
|
||
async def _reply(self, rep, bnd_addr="0.0.0.0", bnd_port=0):
|
||
"""发送 SOCKS5 响应 (RFC 1928 §6).
|
||
|
||
格式: VER(1) REP(1) RSV(1) ATYP(1) BND.ADDR(4/16字节) BND.PORT(2字节)
|
||
IPv4 是 4 字节 packed, IPv6 是 16 字节, 域名不允许出现在 reply.
|
||
"""
|
||
atyp = ATYP_IPV4
|
||
if bnd_addr:
|
||
try:
|
||
ip = ipaddress.ip_address(str(bnd_addr))
|
||
if ip.version == 6:
|
||
atyp = ATYP_IPV6
|
||
addr_bytes = ip.packed
|
||
else:
|
||
atyp = ATYP_IPV4
|
||
addr_bytes = ip.packed
|
||
except ValueError:
|
||
atyp = ATYP_IPV4
|
||
addr_bytes = b"\x00\x00\x00\x00"
|
||
else:
|
||
atyp = ATYP_IPV4
|
||
addr_bytes = b"\x00\x00\x00\x00"
|
||
|
||
self.writer.write(struct.pack(
|
||
"!BBBB",
|
||
SOCKS_VERSION, rep, 0x00, atyp,
|
||
) + addr_bytes + struct.pack("!H", bnd_port))
|
||
await self.writer.drain()
|
||
|
||
async def _handle_connect(self, addr, port):
|
||
"""处理 CONNECT 命令。"""
|
||
self.dst_addr = addr
|
||
self.dst_port = port
|
||
|
||
# IP 白名单/黑名单检查
|
||
if self.username:
|
||
user = self.user_service.get_user(self.username)
|
||
if user and user.ip_blacklist:
|
||
black = {x.strip() for x in user.ip_blacklist.split(",") if x.strip()}
|
||
if self.src_ip in black:
|
||
log.info("[%s] 源IP在黑名单: %s", self.conn_id, self.src_ip)
|
||
await self._reply(REP_CONN_FORBIDDEN)
|
||
await self.user_service.log_event("connect_reject", self.username,
|
||
self.src_ip, f"源IP黑名单 {self.src_ip}")
|
||
return
|
||
if user and user.ip_whitelist:
|
||
white = {x.strip() for x in user.ip_whitelist.split(",") if x.strip()}
|
||
if self.src_ip not in white:
|
||
log.info("[%s] 源IP不在白名单: %s", self.conn_id, self.src_ip)
|
||
await self._reply(REP_CONN_FORBIDDEN)
|
||
await self.user_service.log_event("connect_reject", self.username,
|
||
self.src_ip, f"源IP不在白名单 {self.src_ip}")
|
||
return
|
||
|
||
# 并发连接检查
|
||
if self.username:
|
||
cur_conns = self.user_service.get_active_connections(self.username)
|
||
if self.username:
|
||
user = self.user_service.get_user(self.username)
|
||
if user and cur_conns >= user.max_concurrent:
|
||
log.info("[%s] 并发超限: %s (当前 %d >= %d)",
|
||
self.conn_id, self.username, cur_conns, user.max_concurrent)
|
||
await self._reply(REP_CONN_FORBIDDEN)
|
||
return
|
||
|
||
# 连接目标
|
||
try:
|
||
# 直接用 open_connection,不预连接 socket
|
||
# 在子线程事件循环中 asyncio.wait_for 会导致读取被取消
|
||
reader, writer = await asyncio.open_connection(addr, port)
|
||
except (OSError, Exception) as e:
|
||
log.warning("[%s] 无法连接 %s:%d: %s", self.conn_id, addr, port, e)
|
||
rep = REP_CONN_REFUSED if "refused" in str(e).lower() else REP_HOST_UNREACHABLE
|
||
await self._reply(rep)
|
||
await self.user_service.log_event("connect_reject", self.username,
|
||
self.src_ip, f"连接失败 {addr}:{port}: {e}")
|
||
return
|
||
|
||
# 成功响应
|
||
await self._reply(REP_SUCCEEDED)
|
||
|
||
# 开始隧道
|
||
log.info("[%s] 隧道建立: %s -> %s:%d", self.conn_id,
|
||
self.src_ip, addr, port)
|
||
await self.user_service.log_event("connect_success", self.username,
|
||
self.src_ip, f"{addr}:{port}")
|
||
|
||
await self._tunnel(reader, writer)
|
||
|
||
async def _tunnel(self, dst_reader, dst_writer):
|
||
"""双向隧道转发。"""
|
||
f1 = f2 = None
|
||
cancelled = False
|
||
try:
|
||
# 获取限速参数
|
||
user = None
|
||
bw_down = self.config.bandwidth_down
|
||
bw_up = self.config.bandwidth_up
|
||
if self.username:
|
||
user = self.user_service.get_user(self.username)
|
||
if user and user.bandwidth_down:
|
||
bw_down = user.bandwidth_down
|
||
if user and user.bandwidth_up:
|
||
bw_up = user.bandwidth_up
|
||
|
||
# 启动双向转发
|
||
f1 = asyncio.create_task(self._forward(dst_reader, self.writer,
|
||
"dst->client", bw_down, user))
|
||
f2 = asyncio.create_task(self._forward(self.reader, dst_writer,
|
||
"client->dst", bw_up, user))
|
||
await asyncio.gather(f1, f2, return_exceptions=True)
|
||
|
||
except asyncio.CancelledError:
|
||
cancelled = True
|
||
except GeneratorExit:
|
||
cancelled = True
|
||
except Exception as e:
|
||
log.exception("[%s] 隧道异常: %s", self.conn_id, e)
|
||
finally:
|
||
# 确保转发任务被取消
|
||
for task in [f1, f2]:
|
||
if task and not task.done():
|
||
task.cancel()
|
||
try:
|
||
await task
|
||
except (asyncio.CancelledError, GeneratorExit):
|
||
pass
|
||
self._close_streams(dst_reader, dst_writer)
|
||
# 记录最终统计 - 协程被取消时不做异步操作(避免 GeneratorExit)
|
||
if not cancelled:
|
||
await self._record_stats()
|
||
|
||
async def _forward(self, src, dst, direction, speed_limit_mbps, user):
|
||
"""单向转发,带限速。"""
|
||
if src is None or dst is None:
|
||
return
|
||
total_transferred = 0
|
||
try:
|
||
while True:
|
||
data = await src.read(TUNNEL_CHUNK)
|
||
if not data:
|
||
break
|
||
|
||
# 限速:如果设置了,则节流
|
||
if speed_limit_mbps and speed_limit_mbps > 0:
|
||
bytes_per_sec = speed_limit_mbps * 1000000 / 8
|
||
sleep_time = len(data) / bytes_per_sec
|
||
if sleep_time > 0.001: # 小于 1ms 不节流
|
||
await asyncio.sleep(min(sleep_time, 1.0))
|
||
|
||
dst.write(data) # write() 不是协程,不需要 await
|
||
await dst.drain()
|
||
total_transferred += len(data)
|
||
|
||
if direction == "dst->client":
|
||
self.bytes_in += len(data)
|
||
else:
|
||
self.bytes_out += len(data)
|
||
|
||
except (ConnectionError, BrokenPipeError):
|
||
pass
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception as e:
|
||
log.debug("[%s] 转发异常 %s: %s", self.conn_id, direction, e)
|
||
|
||
def _close_streams(self, dst_reader, dst_writer):
|
||
try:
|
||
if dst_writer is not None:
|
||
dst_writer.close()
|
||
if dst_reader is not None:
|
||
try:
|
||
dst_reader.feed_eof()
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
async def _record_connection(self, state="active"):
|
||
"""记录连接到数据库(异步)。"""
|
||
try:
|
||
ctx = self.flask_app.app_context() if self.flask_app else None
|
||
with ctx:
|
||
models.db.session.add(models.Connection(
|
||
id=self.conn_id,
|
||
instance_id=self.config.instance_id,
|
||
username=self.username,
|
||
src_ip=self.src_ip,
|
||
src_port=self.src_port,
|
||
dst_addr=self.dst_addr,
|
||
dst_port=self.dst_port,
|
||
protocol="SOCKS5",
|
||
state=state,
|
||
started_at=datetime.now(timezone.utc),
|
||
))
|
||
models.db.session.commit()
|
||
except Exception as e:
|
||
log.error("记录连接失败: %s", e)
|
||
|
||
async def _record_stats(self):
|
||
"""记录最终连接统计。"""
|
||
duration = time.time() - self.start_time
|
||
try:
|
||
ctx = self.flask_app.app_context() if self.flask_app else None
|
||
with ctx:
|
||
conn = models.db.session.query(models.Connection).filter_by(id=self.conn_id).first()
|
||
if conn:
|
||
conn.state = "closed"
|
||
conn.ended_at = datetime.now(timezone.utc)
|
||
conn.bytes_in = self.bytes_in
|
||
conn.bytes_out = self.bytes_out
|
||
models.db.session.commit()
|
||
|
||
# 更新用户流量
|
||
if self.username and (self.bytes_in or self.bytes_out):
|
||
await self.user_service.add_traffic(self.username, self.bytes_in, self.bytes_out)
|
||
except Exception as e:
|
||
log.error("记录统计失败: %s", e)
|
||
|
||
def _close(self):
|
||
"""关闭连接。"""
|
||
if self._closed:
|
||
return
|
||
self._closed = True
|
||
try:
|
||
self.writer.close()
|
||
except Exception:
|
||
pass
|
||
if self.on_close:
|
||
try:
|
||
self.on_close(self)
|
||
except Exception:
|
||
pass
|