Skip to content

Commit 592da4d

Browse files
authored
fix(dgw): downgrade benign client disconnects from ERROR to DEBUG (#1620)
Reduces log noise by treating common socket disconnections (BrokenPipe, ConnectionReset, UnexpectedEof) as benign events during HTTP/HTTPS serving and TLS handshake. These disconnects typically occur from health checks, port scanners, aborted browser requests, or early connection termination, and do not indicate server faults. ERROR logs now only appear for genuine server issues, making it easier to identify actionable problems in production deployments. Issue: DGW-319
1 parent 70c2b0e commit 592da4d

File tree

3 files changed

+156
-11
lines changed

3 files changed

+156
-11
lines changed

devolutions-gateway/src/listener.rs

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ use anyhow::Context;
44
use async_trait::async_trait;
55
use devolutions_gateway_task::{ChildTask, ShutdownSignal, Task};
66
use futures::TryFutureExt as _;
7-
use tap::Pipe as _;
87
use tokio::io::{AsyncRead, AsyncWrite};
98
use tokio::net::{TcpListener, TcpSocket, TcpStream};
109
use tracing::Instrument as _;
@@ -220,17 +219,39 @@ async fn run_https_listener(listener: TcpListener, state: DgwState) -> anyhow::R
220219
}
221220
}
222221

222+
/// Checks if an error represents a benign client disconnect.
223+
///
224+
/// Walks the error chain and returns true if any cause is a `std::io::Error`
225+
/// with kind `BrokenPipe`, `ConnectionReset`, or `UnexpectedEof`.
226+
fn is_benign_disconnect(err: &anyhow::Error) -> bool {
227+
use std::io::ErrorKind::{BrokenPipe, ConnectionReset, UnexpectedEof};
228+
229+
err.chain().any(|cause| {
230+
if let Some(ioe) = cause.downcast_ref::<std::io::Error>() {
231+
return matches!(ioe.kind(), BrokenPipe | ConnectionReset | UnexpectedEof);
232+
}
233+
false
234+
})
235+
}
236+
223237
async fn handle_https_peer(
224238
stream: TcpStream,
225239
tls_acceptor: tokio_rustls::TlsAcceptor,
226240
state: DgwState,
227241
peer_addr: SocketAddr,
228242
) -> anyhow::Result<()> {
229-
let tls_stream = tls_acceptor
230-
.accept(stream)
231-
.await
232-
.context("TLS handshake failed")?
233-
.pipe(tokio_rustls::TlsStream::Server);
243+
let tls_stream = match tls_acceptor.accept(stream).await {
244+
Ok(stream) => tokio_rustls::TlsStream::Server(stream),
245+
Err(e) => {
246+
let e = anyhow::Error::from(e);
247+
if is_benign_disconnect(&e) {
248+
debug!(error = format!("{e:#}"), %peer_addr, "TLS handshake ended by peer");
249+
return Ok(());
250+
} else {
251+
return Err(e.context("TLS handshake failed"));
252+
}
253+
}
254+
};
234255

235256
handle_http_peer(tls_stream, state, peer_addr).await
236257
}
@@ -259,13 +280,24 @@ where
259280

260281
match result {
261282
Ok(()) => Ok(()),
262-
Err(e) => match e.downcast_ref::<hyper::Error>() {
263-
Some(e) if e.is_canceled() || e.is_incomplete_message() => {
264-
debug!(error = %e, "Request was cancelled");
283+
Err(error) => {
284+
// Check for hyper-specific benign cases first.
285+
if let Some(hyper_err) = error.downcast_ref::<hyper::Error>()
286+
&& (hyper_err.is_canceled() || hyper_err.is_incomplete_message())
287+
{
288+
debug!(error = format!("{:#}", anyhow::anyhow!(error)), %peer_addr, "Request was cancelled/incomplete");
289+
return Ok(());
290+
}
291+
292+
// Then check for underlying io::Error kinds via anyhow chain.
293+
let error = anyhow::Error::from_boxed(error);
294+
if is_benign_disconnect(&error) {
295+
debug!(error = format!("{error:#}"), %peer_addr, "Client disconnected");
265296
Ok(())
297+
} else {
298+
Err(error.context("HTTP server"))
266299
}
267-
_ => Err(anyhow::anyhow!(e).context("HTTP server")),
268-
},
300+
}
269301
}
270302
}
271303

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
//! Tests for benign client disconnect handling.
2+
//!
3+
//! These tests verify that common client disconnects (e.g., health checks, aborted
4+
//! connections) are logged at DEBUG level instead of ERROR.
5+
//!
6+
//! Note: HTTPS disconnect handling is implemented in handle_https_peer and uses the same
7+
//! benign disconnect detection logic. However, testing HTTPS requires TLS configuration
8+
//! which is not included in the default test setup.
9+
10+
use anyhow::Context as _;
11+
use rstest::rstest;
12+
use testsuite::cli::{dgw_tokio_cmd, wait_for_tcp_port};
13+
use testsuite::dgw_config::{DgwConfig, DgwConfigHandle, VerbosityProfile};
14+
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
15+
use tokio::process::Child;
16+
17+
/// Starts a gateway instance and returns the process and a handle to collect stderr.
18+
///
19+
/// The gateway is configured with DEBUG logging enabled to capture disconnect logs.
20+
/// Stderr is collected in a background task and returned when the handle is awaited.
21+
async fn start_gateway_with_logs(
22+
config_handle: &DgwConfigHandle,
23+
) -> anyhow::Result<(Child, tokio::task::JoinHandle<Vec<String>>)> {
24+
let mut process = dgw_tokio_cmd()
25+
.env("DGATEWAY_CONFIG_PATH", config_handle.config_dir())
26+
.env("RUST_LOG", "devolutions_gateway=debug")
27+
.kill_on_drop(true)
28+
.stdout(std::process::Stdio::piped())
29+
.stderr(std::process::Stdio::piped())
30+
.spawn()
31+
.context("failed to start Devolutions Gateway")?;
32+
33+
let stderr = process.stderr.take().context("failed to take stderr")?;
34+
let stderr_handle = tokio::spawn(async move {
35+
let mut stderr_reader = BufReader::new(stderr);
36+
let mut lines = Vec::new();
37+
let mut line_buf = String::new();
38+
loop {
39+
match stderr_reader.read_line(&mut line_buf).await {
40+
Ok(0) => break,
41+
Ok(_) => {
42+
lines.push(line_buf.clone());
43+
line_buf.clear();
44+
}
45+
Err(_) => break,
46+
}
47+
}
48+
lines
49+
});
50+
51+
// Wait for HTTP port to be ready.
52+
wait_for_tcp_port(config_handle.http_port()).await?;
53+
54+
Ok((process, stderr_handle))
55+
}
56+
57+
/// Test that benign HTTP disconnects log DEBUG, not ERROR.
58+
///
59+
/// Tests various scenarios where clients disconnect without error:
60+
/// - Connecting and immediately closing (e.g., health checks, port scanners)
61+
/// - Sending partial request then closing (e.g., aborted browser requests)
62+
#[rstest]
63+
#[case::connect_and_close(None)]
64+
#[case::abort_mid_request(Some("GET /jet/health HTTP/1.1\r\nHost: localhost\r\n".as_bytes()))]
65+
#[tokio::test]
66+
async fn benign_http_disconnect(#[case] payload: Option<&[u8]>) -> anyhow::Result<()> {
67+
// 1) Start the gateway with DEBUG logging.
68+
let config_handle = DgwConfig::builder()
69+
.disable_token_validation(true)
70+
.verbosity_profile(VerbosityProfile::DEBUG)
71+
.build()
72+
.init()
73+
.context("init config")?;
74+
75+
let (mut process, stderr_handle) = start_gateway_with_logs(&config_handle).await?;
76+
77+
// 2) Connect to HTTP port.
78+
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", config_handle.http_port()))
79+
.await
80+
.context("failed to connect to HTTP port")?;
81+
82+
// 3) Send payload if provided.
83+
if let Some(data) = payload {
84+
stream.write_all(data).await.context("failed to send payload")?;
85+
}
86+
87+
// 4) Close the connection.
88+
drop(stream);
89+
90+
// Wait a bit for the log to be written.
91+
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
92+
93+
// 5) Stop the gateway and collect logs.
94+
let _ = process.start_kill();
95+
let stderr_lines = tokio::time::timeout(tokio::time::Duration::from_secs(5), stderr_handle)
96+
.await
97+
.context("timeout waiting for stderr")?
98+
.context("wait for stderr collection")?;
99+
let _ = process.wait().await;
100+
101+
// 6) Verify no ERROR logs about "HTTP server" or "handle_http_peer failed".
102+
let has_error = stderr_lines.iter().any(|line| {
103+
line.contains("ERROR") && (line.contains("HTTP server") || line.contains("handle_http_peer failed"))
104+
});
105+
106+
assert!(
107+
!has_error,
108+
"Expected no ERROR logs for benign HTTP disconnect, but found one"
109+
);
110+
111+
Ok(())
112+
}

testsuite/tests/cli/dgw/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod ai_gateway;
2+
mod benign_disconnect;
23
mod preflight;
34
mod tls_anchoring;
45
mod traffic_audit;

0 commit comments

Comments
 (0)