diff --git a/src/net/uvcpp_tcp_client.cpp b/src/net/uvcpp_tcp_client.cpp index af105c73..bbf744ac 100644 --- a/src/net/uvcpp_tcp_client.cpp +++ b/src/net/uvcpp_tcp_client.cpp @@ -11,6 +11,7 @@ #include #include #include +#include // 握手超时(见 tls_arm_handshake_timer) #include #include @@ -153,6 +154,17 @@ uvcpp_tcp_client::~uvcpp_tcp_client() { // TLS 读路径上 `tls_ssl_` 正压在栈上(`tls_feed` / `tls_flush_out` 还在 // 下面那几层里),不能删。 tls_ssl_ = nullptr; + + // 握手超时定时器:先停掉(停是安全的,从它自己的回调里停也行)。 + // 释放要分情况 —— 如果本对象正是在那个超时回调里被拆掉的,删除句柄等于 + // 析构正在执行的闭包;那种情况只停不放,让句柄随循环一起走。 + if (tls_hs_timer_ != nullptr) { + tls_hs_timer_->stop(); + if (!tls_hs_in_cb_) { + delete tls_hs_timer_; + } + tls_hs_timer_ = nullptr; + } #endif loop_ = nullptr; tcp_ = nullptr; @@ -170,6 +182,10 @@ uvcpp_tcp_client::~uvcpp_tcp_client() { } #if UVCPP_OPENSSL_ENABLE + // 握手超时定时器(惰性创建,握手成功即释放;只有超时那一条路上会活到 + // 这里)。这条分支不在任何回调栈上,可以安全释放。 + tls_disarm_handshake_timer(); + // TLS 对象归本客户端所有,跟着一起走。注意这里**不发 close_notify**: // 发它要先把 wbio 里的字节异步写出去,而析构路径上没有事件循环可等。 // 所以对端看到的是 TCP 层的断开(`SSL_ERROR_SYSCALL` / 提前 EOF)而不是 @@ -1822,6 +1838,52 @@ void uvcpp_tcp_client::set_tls_ready_callback( int uvcpp_tcp_client::tls_last_ssl_error() const { return tls_ssl_error_; } +void uvcpp_tcp_client::set_tls_handshake_timeout_ms(int ms) { + tls_hs_timeout_ms_ = ms > 0 ? ms : 0; +} + +void uvcpp_tcp_client::tls_arm_handshake_timer() { + // 只有"握手已经开始、且还没结束"才需要计时。tls_drive_handshake() 每次读 + // 事件都会调进来,所以这里必须廉价且幂等。 + if (tls_hs_timeout_ms_ <= 0) return; + if (tls_handshake_done_ || tls_ssl_ == nullptr) return; + if (tls_hs_timer_ != nullptr) return; // 已经在计了,不重起 + if (loop_ == nullptr) return; + + tls_hs_timer_ = new uvcpp_timer(loop_); + tls_hs_timer_->start( + [this](uvcpp_timer*) { + // 记下"此刻正跑在超时回调里"。析构要用它判断能不能安全释放这个句柄: + // 本回调下面的 tls_fail() 会把整条连接拆掉、把本对象释放掉,而那一切 + // 都发生在**这个回调的栈帧还在**的时候 —— 那时析构它就是在踩正在执行 + // 的闭包。 + tls_hs_in_cb_ = true; + + // **回调里只 stop,不 delete**,理由同上。 + if (tls_hs_timer_ != nullptr) tls_hs_timer_->stop(); + if (tls_handshake_done_) return; + + std::fprintf(stderr, + "[uvcpp_tcp_client] TLS handshake timed out after %d ms, " + "closing connection\n", + tls_hs_timeout_ms_); + // 走与"握手失败"完全相同的那条路:通知上层(绝不把连接交付出去) + // → 读侧收尾 → 关闭回调 → 服务端摘表并释放本对象。 + // + // **必须是本回调的最后一句**:tls_fail 会一路走到释放本对象, + // 之后再碰任何成员都是 use-after-free。 + tls_fail(UV_ETIMEDOUT, false); + }, + static_cast(tls_hs_timeout_ms_), 0); // repeat=0:一次性 +} + +void uvcpp_tcp_client::tls_disarm_handshake_timer() { + if (tls_hs_timer_ == nullptr) return; + tls_hs_timer_->stop(); + delete tls_hs_timer_; + tls_hs_timer_ = nullptr; +} + bool uvcpp_tcp_client::set_tls_alpn_protos(const std::vector& protos) { if (tls_handshake_done_) return false; // 协商已经过去了,改不了 tls_alpn_want_ = protos; @@ -1832,6 +1894,10 @@ bool uvcpp_tcp_client::set_tls_alpn_protos(const std::vector& proto int uvcpp_tcp_client::tls_drive_handshake() { if (tls_ssl_ == nullptr || tls_handshake_done_) return 0; + // 握手从这一刻起算超时(幂等)。放在这里而不是 enable_tls() 里,是因为 + // 服务端与客户端两条路都汇聚到本函数推进握手 —— 一处起算,两处生效。 + tls_arm_handshake_timer(); + const int rc = tls_ssl_->handshake(); if (rc < 0) { // 真出错(不是 WANT_READ/WANT_WRITE —— 那两个在本层被归一化成 0)。 @@ -1842,6 +1908,8 @@ int uvcpp_tcp_client::tls_drive_handshake() { } if (rc == 1) { tls_handshake_done_ = true; + // 握手结束了,这条连接不再需要那道闸门。正常路径上定时器只活了几十微秒。 + tls_disarm_handshake_timer(); tls_ssl_error_ = tls_ssl_->last_ssl_error(); // 在**这里**取 ALPN 结果,而不是等上层来问:ready 回调的契约是"回调里 // 对象可能已被析构",从回调里反查 SSL* 不安全。这里是唯一置真点、被五个 @@ -2090,6 +2158,15 @@ void uvcpp_tcp_client::tls_finish_write(int status) { } void uvcpp_tcp_client::tls_fail(int err, bool peer_closed) { + // 任何一条失败路径上都不再需要握手超时。**放在最前面**:下面每一条分支都 + // 可能把本对象释放掉,之后再碰成员就是 use-after-free。 + // + // 这里**只 stop、不 delete**:本函数可能是从定时器回调里被调用的(超时那 + // 一条路),而在回调压栈期间析构这个句柄就是踩正在执行的闭包 —— 与析构里 + // 那段"三层悬垂"记的是同一件事。释放留给析构(正常路径)或"析构时发现 + // 自己在回调里"那一支(只停不放,见 ~uvcpp_tcp_client)。 + if (tls_hs_timer_ != nullptr) tls_hs_timer_->stop(); + const int code = (err != 0) ? err : (peer_closed ? UV_EOF : UV_EPROTO); if (err != 0) { last_error_code_ = err; diff --git a/src/net/uvcpp_tcp_client.h b/src/net/uvcpp_tcp_client.h index df0415e7..d4154e08 100644 --- a/src/net/uvcpp_tcp_client.h +++ b/src/net/uvcpp_tcp_client.h @@ -29,6 +29,7 @@ namespace uvcpp { class uvcpp_ssl; // 见 src/ssl/uvcpp_ssl.h class uvcpp_ssl_context; #endif +class uvcpp_timer; // 见 src/handle/uvcpp_timer.h(握手超时用,只存指针) /** * @brief Bitmask status flags for TCP client lifecycle. @@ -223,6 +224,33 @@ class UVCPP_API uvcpp_tcp_client { * 触发,所以回调里看到的是一个还活着的对象。 */ void set_tls_ready_callback(std::function cb); + + /** + * @brief TLS **握手阶段**的超时(毫秒)。**0 = 不设超时**。默认 10000。 + * + * 为什么必须有它 + * -------------- + * 服务端 accept 之后要等握手成功才把连接交给上层(见 `set_tls_ready_callback` + * 的说明),而**在这之前这条连接不在上层任何登记表里** —— webapp 那一层的 + * 闲置扫描遍历的是自己的连接表,握手期的连接根本不在里面,`idle_timeout_ms` + * 因此管不到它。 + * + * 于是有一条明文的 slowloris 覆盖不到的路:客户端连上之后,每 500 ms 发一个 + * 字节去喂 ClientHello,握手永远差一点不完成。这条连接会连同它的 SSL 对象、 + * 读写缓冲区**无限期挂着**,而占用的带宽近乎为零。明文那条路有 + * `idle_timeout_ms` 兜着(判据是"从请求的第一个字节起算"),但那条规则的前提 + * 是"连接已经在登记表里" —— 握手期的连接不满足这个前提。 + * + * 所以这不是 `idle_timeout_ms` 的替代品,而是它**覆盖不到的那一段**的补位: + * 上限从握手开始(第一次 `tls_drive_handshake()`)起算,握手一结束(无论成功 + * 还是失败)就撤销。 + * + * @note 客户端一侧同样生效 —— "连上了但对端永远不把握手走完"也是同一种占用。 + */ + void set_tls_handshake_timeout_ms(int ms); + + /** @brief 当前的握手超时;0 表示不设。 */ + int tls_handshake_timeout_ms() const { return tls_hs_timeout_ms_; } #endif // UVCPP_OPENSSL_ENABLE // ----------------------------------------------------------------- @@ -749,6 +777,36 @@ class UVCPP_API uvcpp_tcp_client { bool tls_write_sync_ = false; ///< 上面那个写走的是同步接口 int tls_write_status_ = 0; ///< 上面那个写的最终状态 std::function tls_ready_cb_; ///< 握手结束通知一次 + + /** 起算握手超时。幂等:已经起过就不重起(每次读事件都会调一次)。 */ + void tls_arm_handshake_timer(); + /** + * @brief 停掉握手超时**并释放句柄**。 + * + * @warning **绝不能在定时器自己的回调里调它** —— 那等于在回调压栈期间把 + * 正在执行的那个闭包析构掉(与本文件析构注释里记的"三层悬垂"是 + * 同一类)。要停就从回调里直接 `tls_hs_timer_->stop()`,或者走 + * `tls_fail()`(它只 stop、不释放)。 + */ + void tls_disarm_handshake_timer(); + + /** + * @brief 握手超时用的定时器。 + * + * 惰性创建(第一次推进握手时),握手成功即释放 —— 正常路径上它只活几十 + * 微秒。只有"真的超时了"这一条路上它会活到连接被拆掉为止,那时析构会收掉。 + */ + uvcpp_timer* tls_hs_timer_ = nullptr; + /** 握手超时毫秒数;0 = 不设。 */ + int tls_hs_timeout_ms_ = 10000; + /** + * @brief 此刻是否正跑在握手超时的回调里。 + * + * 只给析构看:超时回调会一路走到 `tls_fail()` → 拆连接 → 释放本对象,而 + * 那一切都在**这个回调的栈帧还在**的时候发生。析构据此决定 `tls_hs_timer_` + * 是能安全 delete,还是只能 stop 之后留着(否则就是析构一个正在执行的回调)。 + */ + bool tls_hs_in_cb_ = false; #endif // UVCPP_OPENSSL_ENABLE }; diff --git a/src/net/uvcpp_tcp_server.cpp b/src/net/uvcpp_tcp_server.cpp index bf21c303..664050ab 100644 --- a/src/net/uvcpp_tcp_server.cpp +++ b/src/net/uvcpp_tcp_server.cpp @@ -235,12 +235,17 @@ int uvcpp_tcp_server::listen( // `delete`:此刻句柄是活的,且已经进了登记表。 bool tls_handshake_pending = false; if (ssl_ctx_ != nullptr) { + // 握手超时**必须在 enable_tls 之前设**:`enable_tls()` 里就会推进 + // 一次握手,而超时是从"第一次推进"起算的 —— 设晚了对这一步不生效。 + client->set_tls_handshake_timeout_ms(tls_hs_timeout_ms_); + const int tls_rc = client->enable_tls(ssl_ctx_); if (tls_rc != 0) { last_error_code_ = tls_rc; close_client_on_callback_error(client); return; } + tls_handshake_pending = !client->is_tls_handshake_done(); if (tls_handshake_pending) { // 通知只是把 `on_connection` 挂到"握手成功"那一刻。 @@ -425,6 +430,10 @@ void uvcpp_tcp_server::set_ssl_context(uvcpp_ssl_context* ctx) { ssl_ctx_ = ctx; } +void uvcpp_tcp_server::set_tls_handshake_timeout_ms(int ms) { + tls_hs_timeout_ms_ = ms > 0 ? ms : 0; +} + void uvcpp_tcp_server::on_tls_handshake_done(uvcpp_tcp_client* client, int status) { if (client == nullptr) return; diff --git a/src/net/uvcpp_tcp_server.h b/src/net/uvcpp_tcp_server.h index 7c16ab40..039f4bc2 100644 --- a/src/net/uvcpp_tcp_server.h +++ b/src/net/uvcpp_tcp_server.h @@ -348,6 +348,22 @@ class UVCPP_API uvcpp_tcp_server { /** @brief 当前的 TLS 上下文;没装 TLS 时为 nullptr。 */ uvcpp_ssl_context* ssl_context() const { return ssl_ctx_; } + + /** + * @brief TLS 握手的超时(毫秒)。0 = 不设。默认 10000。装 TLS 才有意义。 + * + * 服务端在握手成功之前**不会**把连接交给上层(见 `uvcpp_tcp_client:: + * set_tls_ready_callback`),所以这期间这条连接不在上层的连接表里,上层的 + * 闲置超时看不到它。没有这道闸门时,一个「连上之后每 500 ms 发一个字节喂 + * ClientHello、永远不把握手做完」的客户端可以让连接无限期挂着。 + * + * 值会传给之后 accept 的每一条连接;已经建立的连接不受影响。 + * @warning 必须在 loop 线程调用。 + */ + void set_tls_handshake_timeout_ms(int ms); + + /** @brief 当前的 TLS 握手超时;0 表示不设。 */ + int tls_handshake_timeout_ms() const { return tls_hs_timeout_ms_; } #endif // UVCPP_OPENSSL_ENABLE // ----------------------------------------------------------------- @@ -423,6 +439,8 @@ class UVCPP_API uvcpp_tcp_server { /** @brief 服务端级 TLS 上下文(非拥有)。nullptr = 明文。 */ uvcpp_ssl_context* ssl_ctx_ = nullptr; + /** TLS 握手超时(毫秒);0 = 不设。accept 时传给每条新连接。 */ + int tls_hs_timeout_ms_ = 10000; #endif // UVCPP_OPENSSL_ENABLE // Trampoline — C-style fn ptr + void* to avoid MSVC std::function diff --git a/src/webapp/uvcpp_web_app.cpp b/src/webapp/uvcpp_web_app.cpp index 4d197c41..84dd2669 100644 --- a/src/webapp/uvcpp_web_app.cpp +++ b/src/webapp/uvcpp_web_app.cpp @@ -497,6 +497,16 @@ bool uvcpp_web_app::ssl_enabled() const { return ssl_requested_ && static_cast(ssl_ctx_); } +uvcpp_web_app& uvcpp_web_app::set_tls_handshake_timeout_ms(int ms) { + // `http_` 在构造时就建好了,所以这里拿得到 tcp_server —— 与 + // `set_max_pipelined_requests` 那类"只改配置、等 start() 生效"的 setter + // 不同,这一条是**立刻写进 tcp_server** 的:tcp_server 在 accept 时才读它, + // 只要在 start() 之前调就来得及。 + uvcpp_tcp_server* ts = tcp_server(); + if (ts != nullptr) ts->set_tls_handshake_timeout_ms(ms); + return *this; +} + #endif // UVCPP_OPENSSL_ENABLE // ========================================================================= diff --git a/src/webapp/uvcpp_web_app.h b/src/webapp/uvcpp_web_app.h index 2db9510f..611e03a9 100644 --- a/src/webapp/uvcpp_web_app.h +++ b/src/webapp/uvcpp_web_app.h @@ -430,6 +430,26 @@ class UVCPP_API uvcpp_web_app : public uvcpp_web_context_host { /** @brief TLS 配置失败的原因(空 = 没出错)。 */ const std::string& ssl_error() const { return ssl_error_; } + + /** + * @brief TLS 握手的超时(毫秒)。0 = 不设。默认 10000。 + * + * **为什么 webapp 这一层需要它**:框架在握手成功之前不会把连接交给应用 + * (所以 `on_connection` 与连接登记表都晚于握手),而闲置扫描遍历的正是 + * 那张登记表 —— 于是**握手期的连接对 `idle_timeout_ms` 完全不可见**。 + * 一个「连上之后每 500 ms 发一个字节喂 ClientHello、永远不把握手做完」的 + * 客户端,可以让连接连同它的 SSL 对象与读写缓冲区无限期挂着,带宽占用近乎 + * 为零。这一条补的就是 `idle_timeout_ms` 覆盖不到的那一段。 + * + * 必须在 `start()` 之前调用(值会传给之后 accept 的每条连接)。 + */ + uvcpp_web_app& set_tls_handshake_timeout_ms(int ms); + + /** @brief 当前的 TLS 握手超时;0 表示不设。 */ + int tls_handshake_timeout_ms() { + uvcpp_tcp_server* ts = tcp_server(); + return ts != nullptr ? ts->tls_handshake_timeout_ms() : 0; + } #endif // UVCPP_OPENSSL_ENABLE // ----------------------------------------------------------------- diff --git a/tests/functional/web_ssl_app_func.cpp b/tests/functional/web_ssl_app_func.cpp index 3ce4166b..9debbc9e 100644 --- a/tests/functional/web_ssl_app_func.cpp +++ b/tests/functional/web_ssl_app_func.cpp @@ -181,6 +181,72 @@ void add_hello_route(uvcpp_web_app& app) { }); } +/// 连上去、发一段**永远凑不完整**的 ClientHello,然后看服务端会不会主动关掉它。 +/// +/// 返回 true = 在观察窗口内被服务端关闭。 +/// +/// 为什么这条必须存在:服务端在握手成功之前**不会**把连接交给框架层,于是这 +/// 条连接既不在框架层的连接登记表里,也就碰不到 `idle_timeout_ms` —— 一个 +/// 连上之后只发半个 ClientHello 的客户端,原本可以让连接连同它的 SSL 对象与 +/// 读写缓冲区无限期挂着。这个用例钉的就是"握手阶段也有闸门"。 +/// +/// 故意**不开 TLS** 地用 `uvcpp_tcp_client`:本用例要的恰恰是"线上字节凑不成 +/// 一次完整握手"这个状态,用明文 socket 发 TLS 记录头的前几个字节,对服务端 +/// 来说与一个真正的 TLS 客户端发到一半卡住完全一样。 +bool probe_stalled_handshake(int port, int observe_ms, const char* label) { + // 一条真实 ClientHello 的开头:记录头 + 握手类型 + 长度 + 版本 + 随机数前几位。 + // 到这里就断掉 —— 长度字段声明的 0x0200 字节永远收不满。 + static const unsigned char kPartialHello[] = { + 0x16, 0x03, 0x01, 0x02, 0x00, 0x01, 0x00, 0x01, 0xfc, 0x03, 0x03}; + + std::atomic closed{false}; + std::atomic connect_fired{false}; + std::atomic connect_status{-99}; + std::atomic read_arm_rc{-999}; + std::atomic write_rc{-999}; + + uvcpp_tcp_client client; + const int crc = client.connect("127.0.0.1", port, [&](int st) { + connect_fired.store(true); + connect_status.store(st); + if (st != 0) return; + read_arm_rc.store(client.read_start_events( + [&closed](uvcpp_tcp_client&, const net_read_result& r) { + if (!r.is_data()) closed.store(true); // 对端关闭 / 读错误 + })); + write_rc.store(client.write( + reinterpret_cast(kPartialHello), sizeof(kPartialHello), + [](int) {})); + }); + check(crc == 0, std::string(label) + ": connect() start failed"); + + uvcpp_loop* loop = client.get_loop(); + uvcpp_test::wait_until( + loop, + [&connect_fired, &connect_status, &closed] { + if (!connect_fired.load()) return false; + if (connect_status.load() != 0) return true; + return closed.load(); + }, + observe_ms); + + check(connect_fired.load(), std::string(label) + ": connect cb never fired"); + check(connect_status.load() == 0, + std::string(label) + ": connect status = " + + std::to_string(connect_status.load())); + check(read_arm_rc.load() == 0, + std::string(label) + ": read_start_events returned " + + std::to_string(read_arm_rc.load())); + check(write_rc.load() == 0, + std::string(label) + ": write() returned " + + std::to_string(write_rc.load())); + + const bool was_closed = closed.load(); + client.close(); + uvcpp_test::pump_for(loop, 60); + return was_closed; +} + } // namespace int main() { @@ -356,6 +422,68 @@ int main() { app.join(); } + // ===================================================================== + // 场景 4:TLS 握手阶段的超时(`set_tls_handshake_timeout_ms`) + // + // 服务端在握手成功之前不把连接交给框架层,所以这期间它在框架层的连接表里 + // **不存在** —— `idle_timeout_ms` 遍历的是那张表,因此管不到它。一个只发 + // 半个 ClientHello 的客户端原本可以让连接无限期挂着。本场景钉两件事: + // + // 1. 设了握手超时 → 卡住的握手会被关掉; + // 2. 关掉这道闸门(0)→ 同样的连接**不会**在同一个窗口内被关掉。 + // + // 第 2 条是对照。没有它的话,"被关掉"可能来自别的路径(比如 idle 超时), + // 那样这条用例证明不了是握手超时干的 —— `idle_timeout_ms` 故意留得远大于 + // 观察窗口,把这条路排除掉。 + // ===================================================================== + { + uvcpp_web_app app; + configure_for_test(app); + app.enable_self_signed("localhost", 2048); + app.set_idle_timeout_ms(30000); // 远大于下面的观察窗口 + app.set_tls_handshake_timeout_ms(300); + add_hello_route(app); + + check(app.tls_handshake_timeout_ms() == 300, + "scenario 4: set_tls_handshake_timeout_ms(300) did not take effect " + "(got " + std::to_string(app.tls_handshake_timeout_ms()) + ")"); + + const int rc = app.start_background(); + check(rc == 0, "scenario 4: start_background failed with " + + std::to_string(rc) + " (" + app.ssl_error() + ")"); + if (rc == 0) { + check(probe_stalled_handshake(app.bound_port(), 3000, + "handshake_timeout"), + "scenario 4: a connection stalled in the TLS handshake was NOT " + "closed within 3 s despite a 300 ms handshake timeout " + "(idle timeout was 30 s, so nothing else could have closed it)"); + } + app.stop(); + app.join(); + } + + { + uvcpp_web_app app; + configure_for_test(app); + app.enable_self_signed("localhost", 2048); + app.set_idle_timeout_ms(30000); + app.set_tls_handshake_timeout_ms(0); // 关掉这道闸门 + add_hello_route(app); + + const int rc = app.start_background(); + check(rc == 0, "scenario 4-control: start_background failed with " + + std::to_string(rc)); + if (rc == 0) { + check(!probe_stalled_handshake(app.bound_port(), 2000, + "handshake_timeout_disabled"), + "scenario 4-control: with the handshake timeout DISABLED the " + "connection was still closed — this case no longer isolates the " + "handshake timeout, so scenario 4 proves nothing"); + } + app.stop(); + app.join(); + } + if (g_failures == 0) { std::cout << "[web_ssl_app] ALL PASS" << std::endl; return 0;