Skip to content

Commit 94b019f

Browse files
srperensclaude
andauthored
fix(server): harden HTTP(S) accept path against fd exhaustion (#630)
A production deployment became unreachable after five weeks of uptime: internet scanners left ~1100 half-open TLS connections behind, each pinning a file descriptor forever, until the process hit its soft fd limit (1024) and accept() failed with EMFILE -- silently, because axum-server retries accept errors without logging. The server looked healthy (running, idle runtime) while the kernel backlog accepted TCP connections that were never serviced. Add layered protections in a new server_hardening module: - KeepaliveAcceptor: enables TCP keepalive (60s + 15s probes) on accepted connections so the kernel reclaims connections whose peer vanished without a FIN. - FirstByteTimeoutAcceptor: closes connections that never send a byte within 30s of being accepted (post-TLS). hyper's header_read_timeout cannot cover this case because hyper-util's auto builder sniffs the h1/h2 version with an untimed read. The deadline is disarmed by the first byte, so WebSockets and in-flight requests are unaffected. - Explicit 10s TLS handshake timeout (matches the axum-server default, pinned so upstream default changes cannot remove the protection). - header_read_timeout (30s) on the hyper builder to reap idle keep-alive connections between requests. Requires installing TokioTimer: hyper panics per-connection if a timeout is configured without a timer, which axum-server does not install by default. - fd-usage watchdog (Linux): warns at 80% of the soft limit, errors every minute at 95%, so the approach to EMFILE is visible in logs before the server becomes unreachable. Verified end-to-end against a running server: raw-idle closed at 10s, TLS-idle closed at 30s, idle keep-alive closed at 30s, idle WebSocket stays open, no hyper panics. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f14a243 commit 94b019f

5 files changed

Lines changed: 378 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ libc = "0.2"
8181
# TLS
8282
axum-server.workspace = true
8383
rustls = { version = "0.23", features = ["ring"] }
84+
hyper-util = { version = "0.1", features = ["tokio"] } # TokioTimer for hyper connection timeouts
8485
notify = { version = "8", default-features = false, features = ["macos_kqueue"] }
8586

8687
# Authentication
@@ -142,6 +143,7 @@ winresource = "0.1"
142143

143144
[dev-dependencies]
144145
tempfile = "3.27"
146+
tokio = { version = "1.52", features = ["full", "test-util"] }
145147
tower = { version = "0.5", features = ["util"] }
146148
http-body-util = "0.1"
147149
serial_test = "3.5"

backend/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub mod openapi;
3636
pub mod paths;
3737
pub mod ptp_monitor;
3838
pub mod rtsp_server;
39+
pub mod server_hardening;
3940
pub mod sharing;
4041
pub mod state;
4142
pub mod stats;

backend/src/main.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -686,21 +686,51 @@ async fn setup_tls(config: &Config) -> Option<axum_server::tls_rustls::RustlsCon
686686
}
687687

688688
/// Start the HTTP(S) server, binding to the given address.
689+
///
690+
/// The accept path is hardened against fd exhaustion from abandoned
691+
/// connections (TCP keepalive, TLS handshake timeout, header read timeout,
692+
/// fd-usage watchdog) — see `server_hardening` for the rationale.
689693
async fn serve_with_tls(
690694
addr: SocketAddr,
691695
app: axum::Router,
692696
handle: axum_server::Handle<SocketAddr>,
693697
tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
694698
) -> anyhow::Result<()> {
699+
use strom::server_hardening::{
700+
FirstByteTimeoutAcceptor, KeepaliveAcceptor, HEADER_READ_TIMEOUT, TLS_HANDSHAKE_TIMEOUT,
701+
};
702+
703+
strom::server_hardening::spawn_fd_watchdog();
704+
695705
if let Some(tls_config) = tls_config {
696706
info!("Server listening on https://{}", addr);
697-
axum_server::bind_rustls(addr, tls_config)
707+
let acceptor = FirstByteTimeoutAcceptor::new(
708+
axum_server::tls_rustls::RustlsAcceptor::new(tls_config)
709+
.handshake_timeout(TLS_HANDSHAKE_TIMEOUT)
710+
.acceptor(KeepaliveAcceptor),
711+
);
712+
let mut server = axum_server::bind(addr).acceptor(acceptor);
713+
server
714+
.http_builder()
715+
.http1()
716+
// hyper panics if a timeout is set without a timer; axum-server
717+
// does not install one by default.
718+
.timer(hyper_util::rt::TokioTimer::new())
719+
.header_read_timeout(HEADER_READ_TIMEOUT);
720+
server
698721
.handle(handle)
699722
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
700723
.await?;
701724
} else {
702725
info!("Server listening on http://{}", addr);
703-
axum_server::bind(addr)
726+
let mut server =
727+
axum_server::bind(addr).acceptor(FirstByteTimeoutAcceptor::new(KeepaliveAcceptor));
728+
server
729+
.http_builder()
730+
.http1()
731+
.timer(hyper_util::rt::TokioTimer::new())
732+
.header_read_timeout(HEADER_READ_TIMEOUT);
733+
server
704734
.handle(handle)
705735
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
706736
.await?;

0 commit comments

Comments
 (0)