icap_rs/server.rs
1//! # ICAP server implementation in Rust.
2//!
3//! ICAP server with per-service routing and **one handler that can serve multiple
4//! ICAP methods** (`REQMOD`, `RESPMOD`). The server:
5//!
6//! - Supports `OPTIONS`, `REQMOD`, `RESPMOD`;
7//! - Lets you register services with **one handler** and a **list of allowed methods** via
8//! [`ServerBuilder::route`];
9//! - **Supports internal rerouting** via [`ServerBuilder::alias`] and [`ServerBuilder::default_service`]:
10//! map one service name to another (e.g. `/` → `scan`) and choose the default service for empty or `/` path;
11//! - Automatically answers `OPTIONS` per service using the allowed methods;
12//! - Returns `404` for unknown services and `405` for unsupported methods;
13//! - Reads encapsulated **chunked bodies to completion** before parsing (avoids premature close
14//! observed by clients like `c-icap-client`);
15//! - Can limit concurrent connections via a semaphore.
16//! ## Quick example
17//!
18//! ```rust,no_run
19//! use icap_rs::{IcapResult, IncomingRequest, Method, Response, Server, ServiceOptions, StatusCode};
20//!
21//! const ISTAG: &str = "scan-1.0";
22//!
23//! #[tokio::main]
24//! async fn main() -> IcapResult<()> {
25//! let server = Server::builder()
26//! .bind("127.0.0.1:1344")
27//! // One handler for REQMOD and RESPMOD of the "scan" service.
28//! .route(
29//! "scan",
30//! [Method::ReqMod, Method::RespMod],
31//! |req: IncomingRequest| async move {
32//! match req.method() {
33//! Method::ReqMod => Ok(Response::no_content_with_istag(ISTAG)?),
34//! Method::RespMod => Ok(Response::no_content_with_istag(ISTAG)?),
35//! Method::Options => unreachable!("OPTIONS is handled automatically by the server"),
36//! }
37//! },
38//! Some(ServiceOptions::new().with_static_istag(ISTAG)
39//! .with_service("Scan Service")
40//! .allow_204()
41//! .with_preview(2048))
42//! )
43//! // If a client uses `icap://host/`, internally route it to the "scan" service.
44//! .default_service("scan")
45//! .alias("/", "scan")
46//! .with_max_connections(128)
47//! .build()
48//! .await?;
49//!
50//! server.run().await
51//! }
52//! ```
53//!
54//! When a client sends `OPTIONS icap://host/scan`, the server responds with
55//! `Methods: REQMOD, RESPMOD` based on the registration above. If a method not in
56//! the list is used, `405 Method Not Allowed` is returned; unknown services yield `404`.
57
58mod builder;
59mod connection;
60mod errors;
61pub mod handler;
62mod no_modification;
63pub mod options;
64mod preview;
65mod router;
66pub mod timeouts;
67pub use builder::ServerBuilder;
68pub use handler::{BoxError, HandlerError, HandlerResult};
69pub use preview::PreviewDecision;
70pub use router::RouteOutput;
71pub use timeouts::ServerTimeouts;
72
73use std::collections::HashMap;
74use std::future::Future;
75use std::sync::Arc;
76use std::time::Duration;
77use tokio::io::AsyncWriteExt;
78use tokio::net::TcpListener;
79use tokio::sync::{Semaphore, watch};
80use tokio::task::JoinSet;
81use tokio::time::{sleep, timeout};
82use tokio_util::task::TaskTracker;
83use tracing::{error, trace, warn};
84
85use crate::error::IcapResult;
86use crate::request::RequestParserMode;
87pub use crate::server::options::{IsTagHandle, ServiceOptions, TransferBehavior};
88use crate::{Response, StatusCode};
89use router::RouteEntry;
90#[cfg(feature = "tls-rustls")]
91use tokio_rustls::TlsAcceptor;
92
93/// An event emitted during graceful shutdown.
94///
95/// Register a handler with [`ServerBuilder::on_shutdown_event`] to receive these.
96/// When no handler is registered, the server logs via [`tracing::warn`] by default.
97///
98/// # Example
99///
100/// ```rust,no_run
101/// use icap_rs::{IcapResult, Server, ShutdownEvent};
102///
103/// #[tokio::main]
104/// async fn main() -> IcapResult<()> {
105/// let server = Server::builder()
106/// .bind("127.0.0.1:1344")
107/// .on_shutdown_event(|event| match event {
108/// ShutdownEvent::Draining { active_connections, drain_timeout } => {
109/// eprintln!("shutting down: {active_connections} connections in flight");
110/// if let Some(d) = drain_timeout {
111/// eprintln!("force-close in {d:.1?}");
112/// }
113/// }
114/// ShutdownEvent::DrainTimedOut { remaining_connections } => {
115/// eprintln!("drain timed out, cancelling {remaining_connections} connections");
116/// }
117/// _ => {}
118/// })
119/// .build()
120/// .await?;
121///
122/// server.run_until(async { tokio::signal::ctrl_c().await.ok(); }).await
123/// }
124/// ```
125#[derive(Debug, Clone, Copy)]
126#[non_exhaustive]
127pub enum ShutdownEvent {
128 /// Shutdown signal received; drain phase starting.
129 ///
130 /// New connections are refused with `503 Service Unavailable`.
131 /// `active_connections` are still processing requests.
132 /// If `drain_timeout` is set, remaining connections will be force-cancelled after it expires.
133 Draining {
134 /// Number of connections still in flight.
135 active_connections: usize,
136 /// How long until in-flight connections are force-cancelled, if configured.
137 drain_timeout: Option<Duration>,
138 },
139 /// Drain deadline expired; remaining connections are being cancelled.
140 DrainTimedOut {
141 /// Number of connections that are being cancelled.
142 remaining_connections: usize,
143 },
144}
145
146fn default_shutdown_handler(event: ShutdownEvent) {
147 match event {
148 ShutdownEvent::Draining {
149 active_connections,
150 drain_timeout: Some(d),
151 } => warn!(
152 connections = %active_connections,
153 "shutting down; draining {active_connections} active connection(s); \
154 new connections will be refused; force-close in {d:.1?}",
155 ),
156 ShutdownEvent::Draining {
157 active_connections,
158 drain_timeout: None,
159 } => warn!(
160 connections = %active_connections,
161 "shutting down; draining {active_connections} active connection(s); \
162 new connections will be refused",
163 ),
164 ShutdownEvent::DrainTimedOut {
165 remaining_connections,
166 } => warn!(
167 connections = %remaining_connections,
168 "shutdown drain timeout expired; \
169 cancelling {remaining_connections} remaining connection(s)",
170 ),
171 }
172}
173
174/// ICAP server.
175///
176/// Use [`Server::builder`] to construct and run an instance.
177///
178/// # Example
179///
180/// ```rust,no_run
181/// use icap_rs::{IcapResult, IncomingRequest, Method, Response, Server, ServiceOptions};
182///
183/// const ISTAG: &str = "scan-1.0";
184///
185/// #[tokio::main]
186/// async fn main() -> IcapResult<()> {
187/// let server = Server::builder()
188/// .bind("127.0.0.1:1344")
189/// .route(
190/// "scan",
191/// [Method::ReqMod],
192/// |_req: IncomingRequest| async move {
193/// Ok(Response::no_content_with_istag(ISTAG)?)
194/// },
195/// Some(ServiceOptions::new().with_static_istag(ISTAG).with_preview(1024)),
196/// )
197/// .build()
198/// .await?;
199///
200/// server.run().await
201/// }
202/// ```
203pub struct Server {
204 listener: TcpListener,
205 routes: Arc<HashMap<String, RouteEntry>>,
206 conn_limit: Option<Arc<Semaphore>>,
207 advertised_max_conn: Option<usize>,
208 aliases: Arc<HashMap<String, String>>,
209 default_service: Option<String>,
210 request_parser_mode: RequestParserMode,
211 timeouts: ServerTimeouts,
212 max_request_header_bytes: usize,
213 shutdown_handler: Arc<dyn Fn(ShutdownEvent) + Send + Sync>,
214 task_tracker: Option<TaskTracker>,
215 #[cfg(feature = "tls-rustls")]
216 tls: Option<(TlsAcceptor, Duration)>,
217}
218impl Server {
219 /// Create a new [`ServerBuilder`].
220 pub fn builder() -> ServerBuilder {
221 ServerBuilder::default()
222 }
223
224 /// Local socket address the server is bound to.
225 ///
226 /// Useful after binding to an ephemeral port (`127.0.0.1:0`).
227 pub fn local_addr(&self) -> std::io::Result<std::net::SocketAddr> {
228 self.listener.local_addr()
229 }
230
231 /// Run the accept loop until the process is killed.
232 ///
233 /// This is a convenience wrapper around [`run_until`](Self::run_until) with a
234 /// `pending()` shutdown future, meaning the server runs indefinitely.
235 /// Use [`run_until`](Self::run_until) when you need graceful shutdown.
236 pub async fn run(self) -> IcapResult<()> {
237 self.run_until(std::future::pending::<()>()).await
238 }
239
240 /// Run the accept loop until `shutdown` resolves, then drain active connections.
241 ///
242 /// When `shutdown` completes the server stops accepting new connections and
243 /// signals all active keep-alive connections to close after their current
244 /// in-flight request completes. Idle connections (waiting for the next
245 /// request) are closed immediately. The method returns only after every
246 /// active connection handler has finished.
247 ///
248 /// # Example
249 ///
250 /// ```rust,no_run
251 /// use icap_rs::{IcapResult, Server};
252 ///
253 /// #[tokio::main]
254 /// async fn main() -> IcapResult<()> {
255 /// let server = Server::builder()
256 /// .bind("127.0.0.1:1344")
257 /// .build()
258 /// .await?;
259 ///
260 /// // Shut down cleanly on Ctrl-C.
261 /// server.run_until(async { tokio::signal::ctrl_c().await.ok(); }).await
262 /// }
263 /// ```
264 pub async fn run_until<F>(self, shutdown: F) -> IcapResult<()>
265 where
266 F: Future<Output = ()>,
267 {
268 let local_addr = self.listener.local_addr()?;
269 trace!(addr=%local_addr, "ICAP server started");
270
271 let (shutdown_tx, shutdown_rx) = watch::channel(false);
272 let mut tasks: JoinSet<()> = JoinSet::new();
273 let mut shutting_down = false;
274 let mut drain_timer_armed = false;
275 let mut drain_deadline: Option<tokio::time::Instant> = None;
276
277 // Sentinel timer far in the future; reset to a real deadline when drain begins.
278 let drain_timer = sleep(Duration::from_hours(8760));
279 tokio::pin!(shutdown, drain_timer);
280
281 loop {
282 // Break as soon as the shutdown is complete and all connections have finished.
283 if shutting_down && tasks.is_empty() {
284 break;
285 }
286
287 tokio::select! {
288 biased;
289
290 () = &mut shutdown, if !shutting_down => {
291 let _ = shutdown_tx.send(true);
292 shutting_down = true;
293 let active = tasks.len();
294 if active == 0 {
295 break;
296 }
297 (self.shutdown_handler)(ShutdownEvent::Draining {
298 active_connections: active,
299 drain_timeout: self.timeouts.shutdown_drain,
300 });
301 if let Some(d) = self.timeouts.shutdown_drain {
302 let deadline = tokio::time::Instant::now() + d;
303 drain_timer.as_mut().reset(deadline);
304 drain_deadline = Some(deadline);
305 drain_timer_armed = true;
306 }
307 }
308
309 () = &mut drain_timer, if drain_timer_armed => {
310 let remaining = tasks.len();
311 (self.shutdown_handler)(ShutdownEvent::DrainTimedOut {
312 remaining_connections: remaining,
313 });
314 tasks.abort_all();
315 while tasks.join_next().await.is_some() {}
316 trace!(addr=%local_addr, "ICAP server stopped");
317 return Ok(());
318 }
319
320 // Poll finished tasks continuously so tasks.len() stays accurate and
321 // we detect when the last in-flight connection completes during drain.
322 Some(_) = tasks.join_next(), if !tasks.is_empty() => {}
323
324 accept_result = self.listener.accept() => {
325 let (socket, addr) = accept_result?;
326
327 if shutting_down {
328 // Refuse new connections during drain with 503.
329 // For TLS, close the raw TCP socket — no handshake before rejecting.
330 trace!(client=%addr, "refusing connection: server is shutting down");
331 #[cfg(feature = "tls-rustls")]
332 if self.tls.is_some() {
333 tokio::spawn(async move {
334 let mut s = socket;
335 let _ = s.shutdown().await;
336 });
337 continue;
338 }
339 tokio::spawn(async move {
340 let mut s = socket;
341 let _ = Self::write_wire_error_response(
342 &mut s,
343 StatusCode::SERVICE_UNAVAILABLE,
344 "Service Shutting Down",
345 )
346 .await;
347 });
348 continue;
349 }
350
351 trace!(client=%addr, "new connection");
352
353 let (permit_opt, over_limit) = self.conn_limit.as_ref().map_or_else(
354 || (None, false),
355 |sem| {
356 sem.clone()
357 .try_acquire_owned()
358 .map_or_else(|_| (None, true), |p| (Some(p), false))
359 },
360 );
361
362 let routes = Arc::clone(&self.routes);
363 let aliases = Arc::clone(&self.aliases);
364 let default_service = self.default_service.clone();
365 let advertised_max = self.advertised_max_conn;
366 let request_parser_mode = self.request_parser_mode;
367 let timeouts = self.timeouts.clone();
368 let max_request_header_bytes = self.max_request_header_bytes;
369 let conn_shutdown = shutdown_rx.clone();
370
371 #[cfg(feature = "tls-rustls")]
372 let tls = self.tls.clone();
373
374 tasks.spawn(async move {
375 let _permit = permit_opt;
376
377 if over_limit {
378 // Reject before TLS handshake: avoids spending CPU on handshakes
379 // just to reject. For TLS, close the raw TCP socket instead.
380 #[cfg(feature = "tls-rustls")]
381 if tls.is_some() {
382 let mut sock = socket;
383 let _ = sock.shutdown().await;
384 return;
385 }
386
387 let resp =
388 Response::new(StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable")
389 .add_header("Connection", "close");
390 match resp.to_raw() {
391 Ok(bytes) => {
392 let mut sock = socket;
393 if let Err(e) = sock.write_all(&bytes).await {
394 warn!(client=%addr, error=%e, "failed to send 503");
395 } else {
396 let _ = sock.shutdown().await;
397 }
398 }
399 Err(e) => warn!(client=%addr, error=%e, "failed to serialize 503"),
400 }
401 return;
402 }
403
404 #[cfg(feature = "tls-rustls")]
405 if let Some((acceptor, hs_timeout)) = tls {
406 match timeout(hs_timeout, acceptor.accept(socket)).await {
407 Ok(Ok(stream)) => {
408 if let Err(e) = Box::pin(Self::handle_connection(
409 stream,
410 routes,
411 aliases,
412 default_service,
413 advertised_max,
414 request_parser_mode,
415 timeouts,
416 max_request_header_bytes,
417 conn_shutdown,
418 addr,
419 ))
420 .await
421 {
422 error!(client=%addr, error=%e, "error handling TLS connection");
423 }
424 }
425 Ok(Err(e)) => {
426 warn!(client=%addr, error=%e, "TLS handshake failed");
427 }
428 Err(_) => {
429 warn!(
430 client=%addr,
431 timeout=?hs_timeout,
432 "TLS handshake timed out",
433 );
434 }
435 }
436 return;
437 }
438
439 // Plain TCP
440 if let Err(e) = Box::pin(Self::handle_connection(
441 socket,
442 routes,
443 aliases,
444 default_service,
445 advertised_max,
446 request_parser_mode,
447 timeouts,
448 max_request_header_bytes,
449 conn_shutdown,
450 addr,
451 ))
452 .await
453 {
454 error!(client=%addr, error=%e, "error handling connection");
455 }
456 });
457 }
458 }
459 }
460
461 // After all connections drain, wait for any user-registered background tasks.
462 if let Some(ref tracker) = self.task_tracker {
463 tracker.close();
464 match drain_deadline {
465 Some(deadline) => {
466 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
467 if !remaining.is_zero() {
468 let _ = timeout(remaining, tracker.wait()).await;
469 }
470 }
471 None => tracker.wait().await,
472 }
473 }
474
475 trace!(addr=%local_addr, "ICAP server stopped");
476 Ok(())
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use crate::{IncomingRequest, Method};
484 use rstest::rstest;
485 use std::panic::{AssertUnwindSafe, catch_unwind};
486
487 async fn handler_ok(_: IncomingRequest) -> crate::HandlerResult<Response> {
488 Ok(Response::new(StatusCode::OK, "OK")
489 .add_header("Encapsulated", "null-body=0")
490 .add_header("Content-Length", "0"))
491 }
492
493 fn panic_str(res: Result<(), Box<dyn std::any::Any + Send>>) -> String {
494 match res {
495 Ok(()) => String::new(),
496 Err(e) => e.downcast_ref::<&str>().map_or_else(
497 || {
498 e.downcast_ref::<String>()
499 .map_or_else(|| "<non-string panic>".to_string(), Clone::clone)
500 },
501 |s| (*s).to_string(),
502 ),
503 }
504 }
505
506 fn assert_panics_with<F>(f: F, needles: &[&str])
507 where
508 F: FnOnce() + std::panic::UnwindSafe,
509 {
510 let res = catch_unwind(AssertUnwindSafe(f));
511 assert!(res.is_err(), "expected panic, but code did not panic");
512 let msg = panic_str(res);
513 for n in needles {
514 assert!(
515 msg.contains(n),
516 "expected panic message to contain {n:?}, got: {msg}",
517 );
518 }
519 }
520
521 #[test]
522 fn route_allows_different_methods_same_service() {
523 let h1 = handler_ok;
524 let h2 = handler_ok;
525
526 let _builder = Server::builder()
527 .route("/spool", [Method::ReqMod], h1, None)
528 .route("/spool", [Method::RespMod], h2, None);
529 }
530
531 #[rstest]
532 #[case("/spool")]
533 #[case("/svc")]
534 fn route_panics_on_duplicate_method_same_service(#[case] path: &str) {
535 assert_panics_with(
536 || {
537 let h1 = handler_ok;
538 let h2 = handler_ok;
539
540 let builder = Server::builder().route(path, [Method::ReqMod], h1, None);
541 let _ = builder.route(path, [Method::ReqMod], h2, None);
542 },
543 &["Overlapping method route", "REQMOD", path],
544 );
545 }
546
547 #[rstest]
548 #[case(Method::RespMod, "RESPMOD")]
549 #[case(Method::ReqMod, "REQMOD")]
550 fn panics_when_overlapping_multiple_methods(
551 #[case] overlap: Method,
552 #[case] overlap_str: &str,
553 ) {
554 let path = "/svc";
555 assert_panics_with(
556 || {
557 let h = handler_ok;
558 let h2 = handler_ok;
559
560 let builder =
561 Server::builder().route(path, [Method::ReqMod, Method::RespMod], h, None);
562 let _ = builder.route(path, [overlap], h2, None);
563 },
564 &["Overlapping method route", overlap_str, path],
565 );
566 }
567
568 #[rstest]
569 #[case("reqmod", "RESPMOD")]
570 #[case("REQMOD", "respmod")]
571 #[case(" ReqMod ", " ReSpMoD ")]
572 fn route_accepts_methods_case_insensitive(#[case] a: &str, #[case] b: &str) {
573 let h = handler_ok;
574 let _b = Server::builder().route("/svc", [a, b], h, None);
575 }
576
577 #[test]
578 fn route_accepts_mixed_enum_and_string() {
579 let h = handler_ok;
580 let _b = Server::builder().route("/svc", vec![Method::ReqMod, "respmod".into()], h, None);
581 }
582
583 #[rstest]
584 #[case("FOO")]
585 #[case("BAR")]
586 fn route_panics_on_unknown_method(#[case] bad: &str) {
587 assert_panics_with(
588 || {
589 let h = handler_ok;
590 let _ = Server::builder().route("/svc", [bad], h, None);
591 },
592 &["Unknown ICAP method string"],
593 );
594 }
595
596 #[rstest]
597 #[case("OPTIONS")]
598 #[case("options")]
599 #[case(" Options ")]
600 fn route_panics_on_options(#[case] opt: &str) {
601 assert_panics_with(
602 || {
603 let h = handler_ok;
604 let _ = Server::builder().route("/svc", [opt], h, None);
605 },
606 &["OPTIONS"],
607 );
608 }
609
610 #[tokio::test]
611 async fn build_errors_when_default_service_is_unknown() {
612 let result = Server::builder().default_service("missing").build().await;
613 let err = result
614 .err()
615 .expect("unknown default service should fail at build time");
616
617 let msg = err.to_string();
618 assert!(msg.contains("default service"), "unexpected error: {msg}");
619 assert!(msg.contains("missing"), "unexpected error: {msg}");
620 }
621
622 #[tokio::test]
623 async fn build_errors_when_alias_target_is_unknown() {
624 let options = ServiceOptions::new().with_static_istag("svc-1.0");
625 let result = Server::builder()
626 .route_reqmod("svc", handler_ok, Some(options))
627 .alias("alt", "missing")
628 .build()
629 .await;
630 let err = result
631 .err()
632 .expect("unknown alias target should fail at build time");
633
634 let msg = err.to_string();
635 assert!(msg.contains("alias"), "unexpected error: {msg}");
636 assert!(msg.contains("missing"), "unexpected error: {msg}");
637 }
638
639 #[tokio::test]
640 async fn build_errors_when_service_options_are_invalid() {
641 let options = ServiceOptions::new()
642 .with_static_istag("svc-1.0")
643 .add_transfer_rule("exe", TransferBehavior::Preview);
644 let result = Server::builder()
645 .route_reqmod("svc", handler_ok, Some(options))
646 .build()
647 .await;
648 let err = result
649 .err()
650 .expect("invalid service options should fail at build time");
651
652 let msg = err.to_string();
653 assert!(msg.contains("invalid options"), "unexpected error: {msg}");
654 assert!(
655 msg.contains("Default transfer behavior"),
656 "unexpected error: {msg}"
657 );
658 }
659
660 #[tokio::test]
661 async fn build_errors_when_service_options_are_missing() {
662 let result = Server::builder()
663 .route_reqmod("svc", handler_ok, None)
664 .build()
665 .await;
666 let err = result
667 .err()
668 .expect("missing service options should fail at build time");
669
670 let msg = err.to_string();
671 assert!(msg.contains("ServiceOptions"), "unexpected error: {msg}");
672 assert!(msg.contains("ISTag"), "unexpected error: {msg}");
673 }
674}