remote/
lib.rs

1//! Remote copy protocol and networking for distributed file operations
2//!
3//! This crate provides the networking layer and protocol definitions for remote file copying
4//! in the RCP tools suite. It enables efficient distributed copying between remote hosts using
5//! SSH for orchestration and QUIC for high-performance data transfer.
6//!
7//! # Overview
8//!
9//! The remote copy system uses a three-node architecture:
10//!
11//! ```text
12//! Master (rcp)
13//! ├── SSH → Source Host (rcpd)
14//! │   └── QUIC → Master (control)
15//! │   └── QUIC Server (waits for Destination)
16//! └── SSH → Destination Host (rcpd)
17//!     └── QUIC → Master (control)
18//!     └── QUIC Client → Source (data transfer)
19//! ```
20//!
21//! ## Connection Flow
22//!
23//! 1. **Initialization**: Master starts `rcpd` processes on source and destination via SSH
24//! 2. **Control Connections**: Both `rcpd` processes connect back to Master via QUIC
25//! 3. **Address Exchange**: Source starts QUIC server and sends its address to Master
26//! 4. **Direct Connection**: Master forwards address to Destination, which connects to Source
27//! 5. **Data Transfer**: Files flow directly from Source to Destination (not through Master)
28//!
29//! This design ensures efficient data transfer while allowing the Master to coordinate
30//! operations and monitor progress.
31//!
32//! # Key Components
33//!
34//! ## SSH Session Management
35//!
36//! The [`SshSession`] type represents an SSH connection to a remote host and is used to:
37//! - Launch `rcpd` daemons on remote hosts
38//! - Configure connection parameters (user, host, port)
39//!
40//! ## QUIC Networking
41//!
42//! QUIC protocol provides:
43//! - Multiplexed streams over a single connection
44//! - Built-in encryption and authentication
45//! - Efficient data transfer with congestion control
46//!
47//! Key functions:
48//! - [`get_server_with_port_ranges`] - Create QUIC server endpoint with optional port restrictions
49//! - [`get_client_with_port_ranges_and_pinning`] - Create secure QUIC client with certificate pinning
50//! - [`get_endpoint_addr`] - Get the local address of an endpoint
51//!
52//! ## Port Range Configuration
53//!
54//! The [`port_ranges`] module allows restricting QUIC to specific port ranges, useful for
55//! firewall-restricted environments:
56//!
57//! ```rust,no_run
58//! # use remote::get_server_with_port_ranges;
59//! // bind to ports in the 8000-8999 range with default timeouts
60//! // idle_timeout: 10 seconds, keep_alive: 1 second
61//! let (endpoint, cert_fingerprint) = get_server_with_port_ranges(
62//!     Some("8000-8999"),
63//!     10,  // idle_timeout_sec
64//!     1,   // keep_alive_interval_sec
65//! )?;
66//! # Ok::<(), anyhow::Error>(())
67//! ```
68//!
69//! ## Protocol Messages
70//!
71//! The [`protocol`] module defines the message types exchanged between nodes:
72//! - `MasterHello` - Master → rcpd configuration
73//! - `SourceMasterHello` - Source → Master address information
74//! - `RcpdResult` - rcpd → Master operation results
75//! - `TracingHello` - rcpd → Master tracing initialization
76//!
77//! ## Stream Communication
78//!
79//! The [`streams`] module provides high-level abstractions over QUIC streams:
80//! - Bidirectional streams for request/response communication
81//! - Unidirectional streams for tracing and logging
82//! - Object serialization/deserialization using bincode
83//!
84//! ## Remote Tracing
85//!
86//! The [`tracelog`] module enables distributed logging and progress tracking:
87//! - Forward tracing events from remote `rcpd` processes to Master
88//! - Aggregate progress information across multiple remote operations
89//! - Display unified progress for distributed operations
90//!
91//! # Security Model
92//!
93//! The remote copy system implements a defense-in-depth security model using SSH for authentication
94//! and certificate pinning for QUIC connection integrity. This provides protection against
95//! man-in-the-middle (MITM) attacks while maintaining ease of deployment.
96//!
97//! ## Authentication & Authorization
98//!
99//! **SSH is the security perimeter**: All remote operations begin with SSH authentication.
100//! - Initial access control is handled entirely by SSH
101//! - Users must be authenticated and authorized via SSH before any QUIC connections are established
102//! - SSH configuration (keys, permissions, etc.) determines who can initiate remote copies
103//!
104//! ## Transport Encryption & Integrity
105//!
106//! **QUIC with TLS 1.3**: All data transfer uses QUIC protocol built on TLS 1.3
107//! - Provides encryption for data confidentiality
108//! - Ensures data integrity through cryptographic authentication
109//! - Built-in protection against replay attacks
110//!
111//! ## Trust Bootstrap via Certificate Pinning
112//!
113//! **Two secured QUIC connections** in every remote copy operation:
114//!
115//! ### 1. Master ← rcpd (Control Connection)
116//! ```text
117//! Master (rcp)                    Remote Host (rcpd)
118//!    |                                   |
119//!    | 1. SSH connection established     |
120//!    |<--------------------------------->|
121//!    | 2. Master generates self-signed   |
122//!    |    cert, computes SHA-256         |
123//!    |    fingerprint                    |
124//!    |                                   |
125//!    | 3. Launch rcpd via SSH with       |
126//!    |    fingerprint as argument        |
127//!    |---------------------------------->|
128//!    |                                   |
129//!    | 4. rcpd validates Master's cert   |
130//!    |    against received fingerprint   |
131//!    |<---(QUIC + cert pinning)----------|
132//! ```
133//!
134//! - Master generates ephemeral self-signed certificate at startup
135//! - Certificate fingerprint (SHA-256) is passed to rcpd via SSH command-line arguments
136//! - rcpd validates Master's certificate by computing its fingerprint and comparing
137//! - Connection fails if fingerprints don't match (MITM protection)
138//!
139//! ### 2. Source → Destination (Data Transfer Connection)
140//! ```text
141//! Source (rcpd)                   Destination (rcpd)
142//!    |                                   |
143//!    | 1. Source generates self-signed   |
144//!    |    cert, computes SHA-256         |
145//!    |    fingerprint                    |
146//!    |                                   |
147//!    | 2. Send fingerprint + address     |
148//!    |    to Master via secure channel   |
149//!    |---------------------------------->|
150//!    |                    Master         |
151//!    |                      |            |
152//!    | 3. Master forwards   |            |
153//!    |    to Destination    |            |
154//!    |                      |----------->|
155//!    |                                   |
156//!    | 4. Destination validates Source's |
157//!    |    cert against received          |
158//!    |    fingerprint                    |
159//!    |<---(QUIC + cert pinning)----------|
160//! ```
161//!
162//! - Source generates ephemeral self-signed certificate
163//! - Fingerprint is sent to Master over already-secured Master←Source connection
164//! - Master forwards fingerprint to Destination over already-secured Master←Destination connection
165//! - Destination validates Source's certificate against fingerprint
166//! - Direct Source→Destination connection established only after successful validation
167//!
168//! ## SSH as Secure Out-of-Band Channel
169//!
170//! **Key insight**: SSH provides a secure, authenticated channel for bootstrapping QUIC trust
171//!
172//! - Certificate fingerprints are transmitted through SSH (Master→rcpd command-line arguments)
173//! - SSH connection is already authenticated and encrypted
174//! - This creates a "chain of trust":
175//!   1. User trusts SSH (proven by successful authentication)
176//!   2. SSH carries the certificate fingerprint securely
177//!   3. QUIC connection validates against that fingerprint
178//!   4. Therefore, QUIC connection is trustworthy
179//!
180//! ## Attack Resistance
181//!
182//! ### ✅ Protected Against
183//!
184//! - **Man-in-the-Middle (MITM)**: Certificate pinning prevents attackers from impersonating endpoints
185//! - **Replay Attacks**: TLS 1.3 in QUIC provides built-in replay protection
186//! - **Eavesdropping**: All data encrypted with TLS 1.3
187//! - **Tampering**: Cryptographic integrity checks prevent data modification
188//! - **Unauthorized Access**: SSH authentication is required before any operations
189//!
190//! ### ⚠️ Threat Model Assumptions
191//!
192//! - **SSH is secure**: The security model depends on SSH being properly configured and uncompromised
193//! - **Certificate fingerprints are short-lived**: Ephemeral certificates are generated per-session
194//! - **Trusted network for Master**: The machine running Master (rcp) should be trusted
195//!
196//! ## Best Practices
197//!
198//! 1. **Secure SSH Configuration**: Use key-based authentication, disable password auth
199//! 2. **Keep Systems Updated**: Ensure SSH, TLS libraries, and QUIC implementations are current
200//! 3. **Network Segmentation**: Run remote copies on trusted network segments when possible
201//! 4. **Monitor Logs**: Certificate validation failures indicate potential security issues
202//!
203//! # Network Troubleshooting
204//!
205//! Common failure scenarios and their handling:
206//!
207//! ## SSH Connection Fails
208//! - **Cause**: Host unreachable, authentication failure
209//! - **Timeout**: ~30s (SSH default)
210//! - **Error**: Standard SSH error messages
211//!
212//! ## rcpd Cannot Connect to Master
213//! - **Cause**: Firewall blocks QUIC, network routing issue
214//! - **Timeout**: Configurable via `--remote-copy-conn-timeout-sec` (default: 15s)
215//! - **Solution**: Check firewall rules for QUIC ports
216//!
217//! ## Destination Cannot Connect to Source
218//! - **Cause**: Firewall blocks direct connection between hosts
219//! - **Timeout**: Configurable (default: 15s)
220//! - **Solution**: Use `--quic-port-ranges` to specify allowed ports, configure firewall
221//!
222//! For detailed troubleshooting, see the repository's `docs/network_connectivity.md`.
223//!
224//! # Examples
225//!
226//! ## Starting a Remote Copy Daemon
227//!
228//! ```rust,no_run
229//! use remote::{SshSession, protocol::RcpdConfig, start_rcpd};
230//! use std::net::SocketAddr;
231//!
232//! # async fn example() -> anyhow::Result<()> {
233//! let session = SshSession {
234//!     user: Some("user".to_string()),
235//!     host: "example.com".to_string(),
236//!     port: None,
237//! };
238//!
239//! let config = RcpdConfig {
240//!     verbose: 0,
241//!     fail_early: false,
242//!     max_workers: 4,
243//!     max_blocking_threads: 512,
244//!     max_open_files: None,
245//!     ops_throttle: 0,
246//!     iops_throttle: 0,
247//!     chunk_size: 1024 * 1024,
248//!     dereference: false,
249//!     overwrite: false,
250//!     overwrite_compare: String::new(),
251//!     debug_log_prefix: None,
252//!     quic_port_ranges: None,
253//!     quic_idle_timeout_sec: 10,
254//!     quic_keep_alive_interval_sec: 1,
255//!     progress: false,
256//!     progress_delay: None,
257//!     remote_copy_conn_timeout_sec: 15,
258//!     master_cert_fingerprint: Vec::new(),
259//! };
260//! let master_addr: SocketAddr = "192.168.1.100:5000".parse()?;
261//! let server_name = "master-server";
262//!
263//! let process = start_rcpd(&config, &session, &master_addr, server_name).await?;
264//! # Ok(())
265//! # }
266//! ```
267//!
268//! ## Creating a QUIC Server with Port Ranges
269//!
270//! ```rust,no_run
271//! use remote::{get_server_with_port_ranges, get_endpoint_addr};
272//!
273//! # fn example() -> anyhow::Result<()> {
274//! // create server restricted to ports 8000-8999
275//! // timeouts: 10s idle, 1s keep-alive (CLI defaults)
276//! let (endpoint, _cert_fingerprint) = get_server_with_port_ranges(
277//!     Some("8000-8999"),
278//!     10,  // idle_timeout_sec
279//!     1,   // keep_alive_interval_sec
280//! )?;
281//! let addr = get_endpoint_addr(&endpoint)?;
282//! println!("Server listening on: {}", addr);
283//! # Ok(())
284//! # }
285//! ```
286//!
287//! # Module Organization
288//!
289//! - [`port_ranges`] - Port range parsing and UDP socket binding
290//! - [`protocol`] - Protocol message definitions and serialization
291//! - [`streams`] - QUIC stream wrappers with typed message passing
292//! - [`tracelog`] - Remote tracing and progress aggregation
293
294use anyhow::{anyhow, Context};
295use rand::Rng;
296use tracing::instrument;
297
298pub mod port_ranges;
299pub mod protocol;
300pub mod streams;
301pub mod tracelog;
302
303/// Configuration for QUIC connections
304#[derive(Debug, Clone)]
305pub struct QuicConfig {
306    /// Port ranges to use for QUIC connections (e.g., "8000-8999,9000-9999")
307    pub port_ranges: Option<String>,
308    /// Maximum idle time before closing connection (seconds)
309    pub idle_timeout_sec: u64,
310    /// Interval for keep-alive packets (seconds)
311    pub keep_alive_interval_sec: u64,
312    /// Connection timeout for remote operations (seconds)
313    pub conn_timeout_sec: u64,
314}
315
316impl Default for QuicConfig {
317    fn default() -> Self {
318        Self {
319            port_ranges: None,
320            idle_timeout_sec: 10,
321            keep_alive_interval_sec: 1,
322            conn_timeout_sec: 15,
323        }
324    }
325}
326
327impl QuicConfig {
328    /// Create QuicConfig with custom timeout values
329    pub fn with_timeouts(
330        idle_timeout_sec: u64,
331        keep_alive_interval_sec: u64,
332        conn_timeout_sec: u64,
333    ) -> Self {
334        Self {
335            port_ranges: None,
336            idle_timeout_sec,
337            keep_alive_interval_sec,
338            conn_timeout_sec,
339        }
340    }
341
342    /// Set port ranges
343    pub fn with_port_ranges(mut self, ranges: impl Into<String>) -> Self {
344        self.port_ranges = Some(ranges.into());
345        self
346    }
347}
348
349#[derive(Debug, PartialEq)]
350pub struct SshSession {
351    pub user: Option<String>,
352    pub host: String,
353    pub port: Option<u16>,
354}
355
356impl SshSession {
357    pub fn local() -> Self {
358        Self {
359            user: None,
360            host: "localhost".to_string(),
361            port: None,
362        }
363    }
364}
365
366async fn setup_ssh_session(
367    session: &SshSession,
368) -> anyhow::Result<std::sync::Arc<openssh::Session>> {
369    let host = session.host.as_str();
370    let destination = match (session.user.as_deref(), session.port) {
371        (Some(user), Some(port)) => format!("ssh://{user}@{host}:{port}"),
372        (None, Some(port)) => format!("ssh://{}:{}", session.host, port),
373        (Some(user), None) => format!("ssh://{user}@{host}"),
374        (None, None) => format!("ssh://{host}"),
375    };
376    tracing::debug!("Connecting to SSH destination: {}", destination);
377    let session = std::sync::Arc::new(
378        openssh::Session::connect(destination, openssh::KnownHosts::Accept)
379            .await
380            .context("Failed to establish SSH connection")?,
381    );
382    Ok(session)
383}
384
385#[instrument]
386pub async fn wait_for_rcpd_process(
387    process: openssh::Child<std::sync::Arc<openssh::Session>>,
388) -> anyhow::Result<()> {
389    tracing::info!("Waiting on rcpd server on: {:?}", process);
390    // wait for process to exit with a timeout and capture output
391    let output = tokio::time::timeout(
392        std::time::Duration::from_secs(10),
393        process.wait_with_output(),
394    )
395    .await
396    .context("Timeout waiting for rcpd process to exit")?
397    .context("Failed to wait for rcpd process")?;
398    if !output.status.success() {
399        let stdout = String::from_utf8_lossy(&output.stdout);
400        let stderr = String::from_utf8_lossy(&output.stderr);
401        tracing::error!(
402            "rcpd command failed on remote host, status code: {:?}\nstdout:\n{}\nstderr:\n{}",
403            output.status.code(),
404            stdout,
405            stderr
406        );
407        return Err(anyhow!(
408            "rcpd command failed on remote host, status code: {:?}",
409            output.status.code(),
410        ));
411    }
412    // log stderr even on success if there's any output (might contain warnings)
413    if !output.stderr.is_empty() {
414        let stderr = String::from_utf8_lossy(&output.stderr);
415        tracing::debug!("rcpd stderr output:\n{}", stderr);
416    }
417    Ok(())
418}
419
420#[instrument]
421pub async fn start_rcpd(
422    rcpd_config: &protocol::RcpdConfig,
423    session: &SshSession,
424    master_addr: &std::net::SocketAddr,
425    master_server_name: &str,
426) -> anyhow::Result<openssh::Child<std::sync::Arc<openssh::Session>>> {
427    tracing::info!("Starting rcpd server on: {:?}", session);
428    let session = setup_ssh_session(session).await?;
429    // Run rcpd command remotely
430    let current_exe = std::env::current_exe().context("Failed to get current executable path")?;
431    let bin_dir = current_exe
432        .parent()
433        .context("Failed to get parent directory of current executable")?;
434    tracing::debug!("Running rcpd from: {:?}", bin_dir);
435    let rcpd_args = rcpd_config.to_args();
436    tracing::debug!("rcpd arguments: {:?}", rcpd_args);
437    let mut cmd = session.arc_command(format!("{}/rcpd", bin_dir.display()));
438    cmd.arg("--master-addr")
439        .arg(master_addr.to_string())
440        .arg("--server-name")
441        .arg(master_server_name)
442        .args(rcpd_args);
443    // capture stdout and stderr so we can read them later
444    cmd.stdout(openssh::Stdio::piped());
445    cmd.stderr(openssh::Stdio::piped());
446    tracing::info!("Will run remotely: {cmd:?}");
447    cmd.spawn().await.context("Failed to spawn rcpd command")
448}
449
450/// Compute SHA-256 fingerprint of a DER-encoded certificate
451fn compute_cert_fingerprint(cert_der: &[u8]) -> ring::digest::Digest {
452    ring::digest::digest(&ring::digest::SHA256, cert_der)
453}
454
455/// Configure QUIC server with a self-signed certificate
456/// Returns the server config and the SHA-256 fingerprint of the certificate
457fn configure_server(
458    idle_timeout_sec: u64,
459    keep_alive_interval_sec: u64,
460) -> anyhow::Result<(quinn::ServerConfig, Vec<u8>)> {
461    tracing::info!(
462        "Configuring QUIC server (idle_timeout={}s, keep_alive={}s)",
463        idle_timeout_sec,
464        keep_alive_interval_sec
465    );
466    let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()])?;
467    let key_der = cert.serialize_private_key_der();
468    let cert_der = cert.serialize_der()?;
469    let fingerprint = compute_cert_fingerprint(&cert_der);
470    let fingerprint_vec = fingerprint.as_ref().to_vec();
471    tracing::debug!(
472        "Generated certificate with fingerprint: {}",
473        hex::encode(&fingerprint_vec)
474    );
475    let key = rustls::PrivateKey(key_der);
476    let cert = rustls::Certificate(cert_der);
477    let mut server_config = quinn::ServerConfig::with_single_cert(vec![cert], key)
478        .context("Failed to create server config")?;
479    // configure transport timeouts for connection liveness detection
480    let mut transport_config = quinn::TransportConfig::default();
481    transport_config.max_idle_timeout(Some(
482        std::time::Duration::from_secs(idle_timeout_sec)
483            .try_into()
484            .context("Failed to convert idle timeout to VarInt")?,
485    ));
486    transport_config.keep_alive_interval(Some(std::time::Duration::from_secs(
487        keep_alive_interval_sec,
488    )));
489    server_config.transport_config(std::sync::Arc::new(transport_config));
490    Ok((server_config, fingerprint_vec))
491}
492
493#[instrument]
494pub fn get_server_with_port_ranges(
495    port_ranges: Option<&str>,
496    idle_timeout_sec: u64,
497    keep_alive_interval_sec: u64,
498) -> anyhow::Result<(quinn::Endpoint, Vec<u8>)> {
499    let (server_config, cert_fingerprint) =
500        configure_server(idle_timeout_sec, keep_alive_interval_sec)?;
501    let socket = if let Some(ranges_str) = port_ranges {
502        let ranges = port_ranges::PortRanges::parse(ranges_str)?;
503        ranges.bind_udp_socket(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED))?
504    } else {
505        // default behavior: bind to any available port
506        std::net::UdpSocket::bind("0.0.0.0:0")?
507    };
508    let endpoint = quinn::Endpoint::new(
509        quinn::EndpointConfig::default(),
510        Some(server_config),
511        socket,
512        std::sync::Arc::new(quinn::TokioRuntime),
513    )
514    .context("Failed to create QUIC endpoint")?;
515    Ok((endpoint, cert_fingerprint))
516}
517
518// certificate verifier that validates against a pinned certificate fingerprint
519// This prevents MITM attacks by ensuring we're connecting to the expected server
520struct PinnedCertVerifier {
521    expected_fingerprint: Vec<u8>,
522}
523
524impl PinnedCertVerifier {
525    fn new(expected_fingerprint: Vec<u8>) -> Self {
526        Self {
527            expected_fingerprint,
528        }
529    }
530}
531
532impl rustls::client::ServerCertVerifier for PinnedCertVerifier {
533    fn verify_server_cert(
534        &self,
535        end_entity: &rustls::Certificate,
536        _intermediates: &[rustls::Certificate],
537        _server_name: &rustls::ServerName,
538        _scts: &mut dyn Iterator<Item = &[u8]>,
539        _ocsp_response: &[u8],
540        _now: std::time::SystemTime,
541    ) -> Result<rustls::client::ServerCertVerified, rustls::Error> {
542        let received_fingerprint = compute_cert_fingerprint(&end_entity.0);
543        if received_fingerprint.as_ref() == self.expected_fingerprint.as_slice() {
544            tracing::debug!(
545                "Certificate fingerprint validated successfully: {}",
546                hex::encode(&self.expected_fingerprint)
547            );
548            Ok(rustls::client::ServerCertVerified::assertion())
549        } else {
550            tracing::error!(
551                "Certificate fingerprint mismatch! Expected: {}, Got: {}",
552                hex::encode(&self.expected_fingerprint),
553                hex::encode(received_fingerprint)
554            );
555            Err(rustls::Error::InvalidCertificate(
556                rustls::CertificateError::Other(std::sync::Arc::new(std::io::Error::new(
557                    std::io::ErrorKind::InvalidData,
558                    format!(
559                        "Certificate fingerprint mismatch (expected {}, got {})",
560                        hex::encode(&self.expected_fingerprint),
561                        hex::encode(received_fingerprint)
562                    ),
563                ))),
564            ))
565        }
566    }
567}
568
569fn get_local_ip() -> anyhow::Result<std::net::IpAddr> {
570    let socket = std::net::UdpSocket::bind("0.0.0.0:0")?;
571    socket.connect("8.8.8.8:80")?;
572    Ok(socket.local_addr()?.ip())
573}
574
575#[instrument]
576pub fn get_endpoint_addr(endpoint: &quinn::Endpoint) -> anyhow::Result<std::net::SocketAddr> {
577    // endpoint is bound to 0.0.0.0 so we need to get the local IP address
578    let local_ip = get_local_ip().context("Failed to get local IP address")?;
579    let endpoint_addr = endpoint.local_addr()?;
580    Ok(std::net::SocketAddr::new(local_ip, endpoint_addr.port()))
581}
582
583#[instrument]
584pub fn get_random_server_name() -> String {
585    rand::thread_rng()
586        .sample_iter(&rand::distributions::Alphanumeric)
587        .take(20)
588        .map(char::from)
589        .collect()
590}
591
592#[instrument]
593pub fn get_client_with_port_ranges_and_pinning(
594    port_ranges: Option<&str>,
595    cert_fingerprint: Vec<u8>,
596    idle_timeout_sec: u64,
597    keep_alive_interval_sec: u64,
598) -> anyhow::Result<quinn::Endpoint> {
599    tracing::info!(
600        "Creating QUIC client with certificate pinning (fingerprint: {}, idle_timeout={}s, keep_alive={}s)",
601        hex::encode(&cert_fingerprint),
602        idle_timeout_sec,
603        keep_alive_interval_sec
604    );
605    // create a crypto backend with certificate pinning
606    let crypto = rustls::ClientConfig::builder()
607        .with_safe_defaults()
608        .with_custom_certificate_verifier(std::sync::Arc::new(PinnedCertVerifier::new(
609            cert_fingerprint,
610        )))
611        .with_no_client_auth();
612    create_client_endpoint(
613        port_ranges,
614        crypto,
615        idle_timeout_sec,
616        keep_alive_interval_sec,
617    )
618}
619
620// helper function to create client endpoint with given crypto config
621fn create_client_endpoint(
622    port_ranges: Option<&str>,
623    crypto: rustls::ClientConfig,
624    idle_timeout_sec: u64,
625    keep_alive_interval_sec: u64,
626) -> anyhow::Result<quinn::Endpoint> {
627    // create QUIC client config with timeouts
628    let mut client_config = quinn::ClientConfig::new(std::sync::Arc::new(crypto));
629    let mut transport_config = quinn::TransportConfig::default();
630    transport_config.max_idle_timeout(Some(
631        std::time::Duration::from_secs(idle_timeout_sec)
632            .try_into()
633            .context("Failed to convert idle timeout to VarInt")?,
634    ));
635    transport_config.keep_alive_interval(Some(std::time::Duration::from_secs(
636        keep_alive_interval_sec,
637    )));
638    client_config.transport_config(std::sync::Arc::new(transport_config));
639    let socket = if let Some(ranges_str) = port_ranges {
640        let ranges = port_ranges::PortRanges::parse(ranges_str)?;
641        ranges.bind_udp_socket(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED))?
642    } else {
643        // default behavior: bind to any available port
644        std::net::UdpSocket::bind("0.0.0.0:0")?
645    };
646    // create and configure endpoint
647    let mut endpoint = quinn::Endpoint::new(
648        quinn::EndpointConfig::default(),
649        None, // No server config for client
650        socket,
651        std::sync::Arc::new(quinn::TokioRuntime),
652    )
653    .context("Failed to create QUIC endpoint")?;
654    endpoint.set_default_client_config(client_config);
655    Ok(endpoint)
656}
657
658#[cfg(test)]
659pub mod test_defaults {
660    //! Test-only constants for QUIC timeout defaults
661    //! These should not be used in production code - all production code should
662    //! receive timeout values from CLI arguments
663
664    /// Default QUIC idle timeout in seconds for tests
665    pub const DEFAULT_QUIC_IDLE_TIMEOUT_SEC: u64 = 10;
666
667    /// Default QUIC keep-alive interval in seconds for tests
668    pub const DEFAULT_QUIC_KEEP_ALIVE_INTERVAL_SEC: u64 = 1;
669}