当客户端打开空闲套接字时,使用 SSL 的 ThreadingTCPServer 完全冻结

ThreadingTCPServer using SSL completely freezes when a client opens an idle socket

问题:ThreadingTCPServer with ssl 在某些请求上冻结,尽管它应该是多线程的。

解释:

我正在尝试创建一个 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...

但是,如果我让这个服务器打开几个小时或过夜,它会突然挂起。经过几天的测试,我终于能够重现这个问题,尽管我不知道如何解决它。以下是我可以在另一台计算机上 运行 的恶意脚本,它将完全 hang/freeze/ 破坏 我的服务器。

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 秒)将部分解决此问题,但它也会使服务器挂起直到套接字超时(每次我 运行恶意脚本):/

另外:将套接字超时设置为小于 3 分钟是不可能的,因为我需要 t运行sfer 可能需要那么长时间的文件,因此每次恶意时将服务器挂起 >3 分钟脚本 运行 真的很糟糕 :/

另外:恶意脚本需要在 python 终端上 运行 并且不得关闭终端。如果恶意脚本终端关闭,则服务器恢复正常(必须与无数据打开的套接字有关)

EDIT:如上所示,挂起发生在名为 get_request 的服务器函数中。我在文件 socketserver.py line 397 的 python 源代码中发现了以下内容

I assume that selector.select() has returned that the socket is readable before this function was called, so there should be no risk of blocking in get_request().

所以,我假设写这篇文章的人没有考虑导致 get_request() 挂起的 ssl 的特定情况?

问题是 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