From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 245C81FF0B4 for ; Tue, 10 Nov 2026 12:07:38 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id EBD98214BE; Tue, 10 Nov 2026 12:07:37 +0100 (CET) From: "Max R. Carrara" 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 Message-ID: <20261110110728.144880-3-m.carrara@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261110110728.144880-1-m.carrara@proxmox.com> References: <20261110110728.144880-1-m.carrara@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1794308852567 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.313 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: SCIKCFVOVC3FKS6NHT5X3OUUVILBIFAB X-Message-ID-Hash: SCIKCFVOVC3FKS6NHT5X3OUUVILBIFAB X-MailFrom: m.carrara@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Backup Server development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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(timeout: Duration, client: F) -> Result + where + F: FnOnce(TcpStream) -> Fut + Send + 'static, + Fut: Future> + 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) { + let err = result.expect_err("check must fail"); + assert!(err.downcast_ref::().is_some(), "{err:#}"); + } + + fn assert_timeout_error(result: Result) { + 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