当客户端打开空闲套接字时,使用 SSL 的 ThreadingTCPServer 完全冻结
Posted
技术标签:
【中文标题】当客户端打开空闲套接字时,使用 SSL 的 ThreadingTCPServer 完全冻结【英文标题】:ThreadingTCPServer using SSL completely freezes when a client opens an idle socket 【发布时间】:2021-09-13 17:02:29 【问题描述】:问题:带有 ssl 的 ThreadingTCPServer 会冻结某些请求,尽管它应该是多线程的。
解释:
我正在尝试创建一个 https 服务器来处理单独线程上的每个请求,因此即使用户请求需要很长时间,服务器也不应该挂起。
这是我的带有打印语句的代码的简单版本,它最初似乎可以工作:
from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingTCPServer
import ssl
import threading
import time
class ChildHandler(BaseHTTPRequestHandler):
def __init__(self, *args, **kwargs):
print('ID:', threading.get_ident(), '1 - BaseHTTPRequestHandler INIT CALLED')
super().__init__(*args, **kwargs)
def do_GET(self):
print('ID:', threading.get_ident(), '2 - do_GET... working...')
time.sleep(5)
print('ID:', threading.get_ident(), '3 - do_get... done...')
self.send_response(200)
self.end_headers()
def log_message(self, *args): pass
if __name__ == '__main__':
server = ThreadingTCPServer(('', 1443), ChildHandler)
server.socket = ssl.wrap_socket(server.socket, certfile='./fullchain1.pem', keyfile='./privkey1.pem',
server_side=True, ssl_version=ssl.PROTOCOL_TLSv1_2)
server.serve_forever()
如果我打开 2 个指向服务器的 chrome 选项卡并同时连接,我们可以清楚地看到它同时为两个用户提供服务。输出如下:
ID: 49092 1 - BaseHTTPRequestHandler INIT CALLED
ID: 49092 2 - do_GET... working...
ID: 46236 1 - BaseHTTPRequestHandler INIT CALLED
ID: 46236 2 - do_GET... working...
ID: 49092 3 - do_get... done...
ID: 46236 3 - do_get... done...
但是,如果我让这台服务器打开几个小时或过夜,它会突然挂起。经过几天的测试,我终于能够重现这个问题,虽然我不知道如何解决它。以下是我可以在另一台计算机上运行的恶意脚本,它会完全挂起/冻结/破坏我的服务器。
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('MY PUBLIC IP', 1443))
之后我的服务器完全挂起。它实际上所做的就是打开一个套接字,然后将其留在那里......而且我的服务器的控制台中绝对没有输出(因此 BaseHTTPRequestHandler 甚至没有被初始化)。 这怎么可能,客户端不应该仅仅通过连接到我的服务器然后什么都不发送来完全挂起我的线程服务器!
进一步调试:
为了进一步分析,我创建了ThreadingTCPServer
的以下子类,它将在初始化请求处理程序的步骤之前打印:
class AnalyzeVer(ThreadingTCPServer):
def get_request(self):
print('ID:', threading.get_ident(), '-2 - GET REQUEST STARTED')
result = super().get_request()
print('ID:', threading.get_ident(), '-1 - GET REQUEST ENDED')
return result
def verify_request(self, request, client_address):
print('ID:', threading.get_ident(), '0 - VERIFY REQUEST')
return super().verify_request(request, client_address)
当我运行我的恶意脚本时,我的服务器显然完全挂起,并且在我的服务器中得到以下输出:
ID: 30880 -2 - GET REQUEST STARTED
所以它肯定会在 get_request 中冻结。
另外:删除 ssl 证书似乎可以解决这个问题,但我需要这些:/
另外:将套接字超时设置为某个值(例如 10 秒)将部分解决此问题,但它也会使服务器挂起,直到套接字超时(每次运行恶意脚本时冻结 10 秒):/
另外:将套接字超时设置为小于 3 分钟是不可能的,因为我需要传输可能需要很长时间的文件,因此每次运行恶意脚本时将服务器挂起 >3 分钟真的很糟糕:/
另外:恶意脚本需要在 python 终端上运行,并且不能关闭终端。如果恶意脚本终端关闭,则服务器恢复正常(必须与套接字打开而没有数据有关)
编辑:如上所示,挂起发生在名为get_request
的服务器函数中。我在文件socketserver.py line 397的python源代码中发现了以下内容
我假设 selector.select() 在调用这个函数之前已经返回了套接字是可读的,所以在 get_request() 中应该没有阻塞的风险。
所以,我假设写这篇文章的人没有考虑导致get_request()
挂起的 ssl 特定情况?
【问题讨论】:
【参考方案1】:问题是ssl.wrap_socket
已经在侦听器套接字中调用了。这将导致套接字accept
紧随其后的是 TLS 握手 - 只有在完成此操作后,才会产生带有 ChildHandler
的新线程。如果 TLS 握手停止,现在可以处理新连接。 TLS 握手很容易停止:只需 TCP 连接,什么都不发送。
解决方案是在 TCP 接受后立即生成新线程并在新线程中进行 TLS 握手。这样,只有新线程会在未完成的 TLS 握手上停止,而不是主线程。服务器仍然可以通过这种方式接受新的连接。
将 TLS 握手移动到新生成的线程可以通过不在侦听器套接字上执行 ssl.wrap_socket
来完成,而是在接受的新连接上执行:
class ChildHandler(BaseHTTPRequestHandler):
def __init__(self, request, *args, **kwargs):
print('ID:', threading.get_ident(), '1 - BaseHTTPRequestHandler INIT CALLED')
request = ssl.wrap_socket(request, certfile='./fullchain1.pem', keyfile='./privkey1.pem',
server_side=True, ssl_version=ssl.PROTOCOL_TLSv1_2)
super().__init__(request, *args, **kwargs)
...
if __name__ == '__main__':
server = ThreadingTCPServer(('', 1443), ChildHandler)
# no ssl.wrap_socket(server.socket, ...) here
server.serve_forever()
请注意,仍应使用超时或类似方法处理无效的 TLS 握手,以免累积许多停滞的线程。为 TLS 握手设置一个较短的套接字超时并在此之后设置更长的时间是有意义的。只需在调用ssl.wrap_socket
之前和之后使用具有不同值的socket.settimeout。
【讨论】:
太棒了!辉煌!我以为我必须制作自己的自定义线程 tcp 实现才能在套接字之前创建线程。但这是一种更好、更清洁的方法。当然是握手,它是如此明显,但我从未想过我可以在请求处理程序级别启动握手。这太棒了。此外,在握手之前/之后设置不同的套接字超时听起来就像我需要做的另一个好主意。非常感谢!以上是关于当客户端打开空闲套接字时,使用 SSL 的 ThreadingTCPServer 完全冻结的主要内容,如果未能解决你的问题,请参考以下文章