From: "Max R. Carrara" <m.carrara@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [PATCH proxmox v3 2/5] rest-server: connection: add tests for TLS handshake check
Date: Tue, 10 Nov 2026 12:07:24 +0100 [thread overview]
Message-ID: <20261110110728.144880-3-m.carrara@proxmox.com> (raw)
In-Reply-To: <20261110110728.144880-1-m.carrara@proxmox.com>
Checking whether a client initiates a TLS handshake depends on the
client behaves. There are four cases in total:
- the client sends enough data
- sends the data in chunks
- closes or resets the connection early
- or does nothing at all
None of these cases were covered by tests so far.
Therefore, run the TLS handshake check against real local connections
for each of these cases, so that changes in the outcomes or
regressions are caught.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
proxmox-rest-server/src/connection.rs | 192 ++++++++++++++++++++++++++
1 file changed, 192 insertions(+)
diff --git a/proxmox-rest-server/src/connection.rs b/proxmox-rest-server/src/connection.rs
index 3062c4f7..00ee771b 100644
--- a/proxmox-rest-server/src/connection.rs
+++ b/proxmox-rest-server/src/connection.rs
@@ -298,10 +298,11 @@ struct AcceptState {
struct AcceptFlags {
is_debug: bool,
}
/// Outcome of checking whether a client initiates a TLS handshake on a new connection.
+#[derive(Debug)]
enum TlsHandshakeCheck {
/// The client sent the beginning of a TLS handshake.
///
/// See [contains_tls_handshake_fragment] for details on how this is
/// checked.
@@ -590,12 +591,203 @@ fn contains_tls_handshake_fragment(buf: &[u8]) -> bool {
buf[0] == 0x16 && buf[1] == 0x3 && (((buf[3] as u16) << 8) + buf[4] as u16) <= CONTENT_SIZE
}
#[cfg(test)]
mod tests {
+ use std::io;
+ use std::time::Duration;
+
+ use anyhow::Error;
+ use tokio::io::AsyncWriteExt;
+ use tokio::net::TcpListener;
+ use tokio::net::TcpStream;
+
+ use super::AcceptBuilder;
+ use super::TlsHandshakeCheck;
use super::contains_tls_handshake_fragment;
+ /// Timeout for checks that are expected to finish on their own.
+ const CHECK_TIMEOUT: Duration = Duration::from_secs(10);
+
+ /// Timeout for checks that are expected to time out.
+ const SHORT_CHECK_TIMEOUT: Duration = Duration::from_millis(200);
+
+ /// Runs `client` against a fresh local connection and checks that connection on the server side
+ /// for a TLS handshake.
+ ///
+ /// If `client` returns its stream, the connection is kept open until the check has finished.
+ async fn check_client<F, Fut>(timeout: Duration, client: F) -> Result<TlsHandshakeCheck, Error>
+ where
+ F: FnOnce(TcpStream) -> Fut + Send + 'static,
+ Fut: Future<Output = Option<TcpStream>> + Send,
+ {
+ let listener = TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("failed to set up local TCP listener");
+
+ let addr = listener.local_addr().unwrap();
+
+ let client = tokio::spawn(async move {
+ let stream = TcpStream::connect(addr)
+ .await
+ .expect("failed to connect to address of local TCP listener");
+
+ client(stream).await
+ });
+
+ let (server_stream, _) = listener
+ .accept()
+ .await
+ .expect("failed to accept incoming connection");
+
+ let result = AcceptBuilder::wait_for_client_tls_handshake(&server_stream, timeout).await;
+
+ drop(client.await.unwrap());
+
+ result
+ }
+
+ /// Asserts that the check failed because of the connection itself, not because of a timeout.
+ fn assert_connection_error(result: Result<TlsHandshakeCheck, Error>) {
+ let err = result.expect_err("check must fail");
+ assert!(err.downcast_ref::<io::Error>().is_some(), "{err:#}");
+ }
+
+ fn assert_timeout_error(result: Result<TlsHandshakeCheck, Error>) {
+ let err = result.expect_err("check must time out");
+ assert_eq!(
+ format!("{err:#}"),
+ "timed out while waiting for client to initiate TLS handshake: deadline has elapsed",
+ );
+ }
+
+ #[tokio::test]
+ async fn a_tls_client_is_detected() {
+ let result = check_client(CHECK_TIMEOUT, |mut stream| async move {
+ stream
+ .write_all(&[0x16, 0x3, 0x1, 0x02, 0x00])
+ .await
+ .unwrap();
+ Some(stream)
+ })
+ .await;
+
+ assert!(
+ matches!(result, Ok(TlsHandshakeCheck::Initiated)),
+ "{result:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn a_plain_client_is_detected() {
+ let result = check_client(CHECK_TIMEOUT, |mut stream| async move {
+ stream.write_all(b"GET / HTTP/1.1\r\n").await.unwrap();
+ Some(stream)
+ })
+ .await;
+
+ assert!(
+ matches!(result, Ok(TlsHandshakeCheck::NotInitiated)),
+ "{result:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn a_client_sending_small_chunks_is_detected() {
+ let result = check_client(CHECK_TIMEOUT, |mut stream| async move {
+ for chunk in [&b"G"[..], b"E", b"T", b" / HTTP/1.1\r\n"] {
+ stream.write_all(chunk).await.unwrap();
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ }
+ Some(stream)
+ })
+ .await;
+
+ assert!(
+ matches!(result, Ok(TlsHandshakeCheck::NotInitiated)),
+ "{result:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn a_client_closing_without_data_is_reported() {
+ let result = check_client(CHECK_TIMEOUT, |stream| async move {
+ drop(stream);
+ None
+ })
+ .await;
+
+ assert_connection_error(result);
+ }
+
+ #[tokio::test]
+ async fn a_client_closing_after_partial_data_is_reported() {
+ let result = check_client(CHECK_TIMEOUT, |mut stream| async move {
+ stream.write_all(&[0x16, 0x3]).await.unwrap();
+ drop(stream);
+ None
+ })
+ .await;
+
+ assert_connection_error(result);
+ }
+
+ #[tokio::test]
+ async fn a_client_shutting_down_after_partial_data_is_reported() {
+ let result = check_client(CHECK_TIMEOUT, |mut stream| async move {
+ stream.write_all(&[0x16, 0x3]).await.unwrap();
+ stream.shutdown().await.unwrap();
+ Some(stream)
+ })
+ .await;
+
+ assert_connection_error(result);
+ }
+
+ #[tokio::test]
+ async fn a_client_resetting_without_data_is_reported() {
+ let result = check_client(CHECK_TIMEOUT, |stream| async move {
+ stream.set_linger(Some(Duration::ZERO)).unwrap();
+ drop(stream);
+ None
+ })
+ .await;
+
+ assert_connection_error(result);
+ }
+
+ #[tokio::test]
+ async fn a_client_resetting_after_partial_data_is_reported() {
+ let result = check_client(CHECK_TIMEOUT, |mut stream| async move {
+ stream.write_all(&[0x16, 0x3]).await.unwrap();
+ stream.set_linger(Some(Duration::ZERO)).unwrap();
+ drop(stream);
+ None
+ })
+ .await;
+
+ assert_connection_error(result);
+ }
+
+ #[tokio::test]
+ async fn an_idle_client_times_out() {
+ let result = check_client(SHORT_CHECK_TIMEOUT, |stream| async move { Some(stream) }).await;
+
+ assert_timeout_error(result);
+ }
+
+ #[tokio::test]
+ async fn a_client_stalling_after_partial_data_times_out() {
+ let result = check_client(SHORT_CHECK_TIMEOUT, |mut stream| async move {
+ stream.write_all(&[0x16, 0x3]).await.unwrap();
+ Some(stream)
+ })
+ .await;
+
+ assert_timeout_error(result);
+ }
+
#[test]
fn a_handshake_fragment_is_detected() {
// content type 22, major version 3, any minor version, length within 2^14
assert!(contains_tls_handshake_fragment(&[
0x16, 0x3, 0x1, 0x02, 0x00
--
2.47.3
next prev parent reply other threads:[~2026-11-10 11:07 UTC|newest]
Thread overview: 6+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-11-10 11:07 [PATCH proxmox v3 0/5] Fix #6738: Improve TLS Handshake Handling & Error Logging Max R. Carrara
2026-11-10 11:07 ` [PATCH proxmox v3 1/5] rest-server: connection: use enum for TLS handshake check result Max R. Carrara
2026-11-10 11:07 ` Max R. Carrara [this message]
2026-11-10 11:07 ` [PATCH proxmox v3 3/5] rest-server: connection: log cause of TLS handshake check errors Max R. Carrara
2026-11-10 11:07 ` [PATCH proxmox v3 4/5] rest-server: connection: avoid dropping clients during TLS check Max R. Carrara
2026-11-10 11:07 ` [PATCH proxmox v3 5/5] fix #6738: rest-server: log early closed connections at debug level Max R. Carrara
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261110110728.144880-3-m.carrara@proxmox.com \
--to=m.carrara@proxmox.com \
--cc=pbs-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox