ntrip_core/
config.rs

1//! Configuration types for ntrip-core.
2
3use crate::Error;
4use std::env;
5use std::fmt;
6
7/// NTRIP protocol version.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9#[non_exhaustive]
10pub enum NtripVersion {
11    /// NTRIP v1 (HTTP/1.0, ICY 200 OK response)
12    V1,
13    /// NTRIP v2 (HTTP/1.1, chunked transfer encoding)
14    V2,
15    /// Auto-detect from server response (default)
16    #[default]
17    Auto,
18}
19
20/// HTTP proxy configuration.
21#[derive(Clone)]
22pub struct ProxyConfig {
23    /// Proxy hostname or IP address.
24    pub host: String,
25    /// Proxy port.
26    pub port: u16,
27    /// Username for proxy authentication (optional).
28    pub username: Option<String>,
29    /// Password for proxy authentication (optional).
30    pub password: Option<String>,
31}
32
33impl ProxyConfig {
34    /// Create a new proxy configuration.
35    pub fn new(host: impl Into<String>, port: u16) -> Self {
36        Self {
37            host: host.into(),
38            port,
39            username: None,
40            password: None,
41        }
42    }
43
44    /// Set proxy authentication credentials.
45    pub fn with_credentials(
46        mut self,
47        username: impl Into<String>,
48        password: impl Into<String>,
49    ) -> Self {
50        self.username = Some(username.into());
51        self.password = Some(password.into());
52        self
53    }
54
55    /// Parse proxy configuration from a URL string (e.g., "http://user:pass@host:port").
56    pub fn from_url(url: &str) -> Option<Self> {
57        // Strip http:// or https:// prefix
58        let url = url
59            .strip_prefix("http://")
60            .or_else(|| url.strip_prefix("https://"))
61            .unwrap_or(url);
62
63        // Check for credentials (user:pass@host:port)
64        let (auth, host_port) = if let Some(at_pos) = url.rfind('@') {
65            (Some(&url[..at_pos]), &url[at_pos + 1..])
66        } else {
67            (None, url)
68        };
69
70        // Parse host:port
71        let (host, port) = if let Some(colon_pos) = host_port.rfind(':') {
72            let port_str = &host_port[colon_pos + 1..];
73            let port: u16 = port_str.parse().ok()?;
74            (&host_port[..colon_pos], port)
75        } else {
76            (host_port, 8080) // Default proxy port
77        };
78
79        if host.is_empty() {
80            return None;
81        }
82
83        let mut config = ProxyConfig::new(host, port);
84
85        // Parse credentials if present
86        if let Some(auth) = auth {
87            if let Some(colon_pos) = auth.find(':') {
88                let username = &auth[..colon_pos];
89                let password = &auth[colon_pos + 1..];
90                config = config.with_credentials(username, password);
91            }
92        }
93
94        Some(config)
95    }
96
97    /// Read proxy configuration from environment variables.
98    ///
99    /// Checks `$HTTP_PROXY` and `$http_proxy` in that order.
100    /// Returns `None` if no proxy is configured or the URL is invalid.
101    pub fn from_env() -> Option<Self> {
102        env::var("HTTP_PROXY")
103            .or_else(|_| env::var("http_proxy"))
104            .ok()
105            .and_then(|url| Self::from_url(&url))
106    }
107}
108
109impl fmt::Debug for ProxyConfig {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        f.debug_struct("ProxyConfig")
112            .field("host", &self.host)
113            .field("port", &self.port)
114            .field("username", &self.username)
115            .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
116            .finish()
117    }
118}
119
120/// Connection-related configuration.
121#[derive(Debug, Clone)]
122pub struct ConnectionConfig {
123    /// Connection timeout in seconds.
124    pub timeout_secs: u32,
125    /// Read timeout in seconds (0 = no timeout).
126    pub read_timeout_secs: u32,
127    /// Maximum reconnection attempts on disconnect/timeout (0 = disabled).
128    pub max_reconnect_attempts: u32,
129    /// Delay between reconnection attempts in milliseconds.
130    pub reconnect_delay_ms: u64,
131}
132
133impl Default for ConnectionConfig {
134    fn default() -> Self {
135        Self {
136            timeout_secs: 15,
137            read_timeout_secs: 30,
138            max_reconnect_attempts: 3,
139            reconnect_delay_ms: 1000,
140        }
141    }
142}
143
144/// Configuration for an NTRIP client connection.
145#[derive(Clone)]
146pub struct NtripConfig {
147    /// Caster hostname or IP address.
148    pub host: String,
149    /// Caster port (typically 2101).
150    pub port: u16,
151    /// Mountpoint name.
152    pub mountpoint: String,
153    /// Username for authentication.
154    pub username: Option<String>,
155    /// Password for authentication.
156    pub password: Option<String>,
157    /// Use HTTPS/TLS.
158    pub use_tls: bool,
159    /// Skip TLS certificate verification (insecure, for testing only).
160    pub tls_skip_verify: bool,
161    /// NTRIP protocol version.
162    pub ntrip_version: NtripVersion,
163    /// Connection configuration.
164    pub connection: ConnectionConfig,
165    /// HTTP proxy configuration (optional).
166    pub proxy: Option<ProxyConfig>,
167}
168
169impl fmt::Debug for NtripConfig {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        f.debug_struct("NtripConfig")
172            .field("host", &self.host)
173            .field("port", &self.port)
174            .field("mountpoint", &self.mountpoint)
175            .field("username", &self.username)
176            .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
177            .field("use_tls", &self.use_tls)
178            .field("tls_skip_verify", &self.tls_skip_verify)
179            .field("ntrip_version", &self.ntrip_version)
180            .field("connection", &self.connection)
181            .field("proxy", &self.proxy)
182            .finish()
183    }
184}
185
186impl NtripConfig {
187    /// Create a new configuration with required fields.
188    pub fn new(host: impl Into<String>, port: u16, mountpoint: impl Into<String>) -> Self {
189        Self {
190            host: host.into(),
191            port,
192            mountpoint: mountpoint.into(),
193            username: None,
194            password: None,
195            use_tls: false,
196            tls_skip_verify: false,
197            ntrip_version: NtripVersion::Auto,
198            connection: ConnectionConfig::default(),
199            proxy: None,
200        }
201    }
202
203    /// Set credentials for authentication.
204    pub fn with_credentials(
205        mut self,
206        username: impl Into<String>,
207        password: impl Into<String>,
208    ) -> Self {
209        self.username = Some(username.into());
210        self.password = Some(password.into());
211        self
212    }
213
214    /// Enable TLS/HTTPS.
215    pub fn with_tls(mut self) -> Self {
216        self.use_tls = true;
217        self
218    }
219
220    /// Skip TLS certificate verification (insecure).
221    pub fn with_tls_skip_verify(mut self) -> Self {
222        self.tls_skip_verify = true;
223        self
224    }
225
226    /// Set NTRIP protocol version.
227    pub fn with_version(mut self, version: NtripVersion) -> Self {
228        self.ntrip_version = version;
229        self
230    }
231
232    /// Set connection timeout.
233    pub fn with_timeout(mut self, timeout_secs: u32) -> Self {
234        self.connection.timeout_secs = timeout_secs;
235        self
236    }
237
238    /// Set read timeout.
239    pub fn with_read_timeout(mut self, read_timeout_secs: u32) -> Self {
240        self.connection.read_timeout_secs = read_timeout_secs;
241        self
242    }
243
244    /// Set maximum reconnection attempts (0 = disabled).
245    pub fn with_reconnect(mut self, max_attempts: u32, delay_ms: u64) -> Self {
246        self.connection.max_reconnect_attempts = max_attempts;
247        self.connection.reconnect_delay_ms = delay_ms;
248        self
249    }
250
251    /// Disable automatic reconnection.
252    pub fn without_reconnect(mut self) -> Self {
253        self.connection.max_reconnect_attempts = 0;
254        self
255    }
256
257    /// Set HTTP proxy configuration.
258    ///
259    /// When a proxy is configured, the client will connect to the proxy server
260    /// and use HTTP CONNECT to tunnel the connection to the NTRIP caster.
261    pub fn with_proxy(mut self, proxy: ProxyConfig) -> Self {
262        self.proxy = Some(proxy);
263        self
264    }
265
266    /// Configure proxy from environment variables.
267    ///
268    /// Reads `$HTTP_PROXY` or `$http_proxy` environment variable.
269    /// If the variable is not set or invalid, no proxy is configured.
270    ///
271    /// # Example
272    /// ```
273    /// use ntrip_core::NtripConfig;
274    ///
275    /// // Will use proxy from $HTTP_PROXY if set
276    /// let config = NtripConfig::new("caster.example.com", 2101, "MOUNT")
277    ///     .with_proxy_from_env();
278    /// ```
279    pub fn with_proxy_from_env(mut self) -> Self {
280        self.proxy = ProxyConfig::from_env();
281        self
282    }
283
284    /// Validate the configuration.
285    pub fn validate(&self) -> Result<(), Error> {
286        if self.host.is_empty() {
287            return Err(Error::InvalidConfig {
288                message: "Host cannot be empty".to_string(),
289            });
290        }
291        if self.port == 0 {
292            return Err(Error::InvalidConfig {
293                message: "Port cannot be 0".to_string(),
294            });
295        }
296        // Validate against header injection (control characters)
297        Self::validate_no_control_chars(&self.host, "host")?;
298        Self::validate_no_control_chars(&self.mountpoint, "mountpoint")?;
299        if let Some(ref u) = self.username {
300            Self::validate_no_control_chars(u, "username")?;
301        }
302        if let Some(ref p) = self.password {
303            Self::validate_no_control_chars(p, "password")?;
304        }
305        Ok(())
306    }
307
308    /// Validate that a string contains no ASCII control characters (header injection prevention).
309    fn validate_no_control_chars(s: &str, field_name: &str) -> Result<(), Error> {
310        if s.bytes().any(|b| b < 0x20 || b == 0x7F) {
311            return Err(Error::InvalidConfig {
312                message: format!(
313                    "{} contains invalid control characters (possible header injection)",
314                    field_name
315                ),
316            });
317        }
318        Ok(())
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_proxy_config_from_url_simple() {
328        let proxy = ProxyConfig::from_url("proxy.example.com:8080").unwrap();
329        assert_eq!(proxy.host, "proxy.example.com");
330        assert_eq!(proxy.port, 8080);
331        assert!(proxy.username.is_none());
332        assert!(proxy.password.is_none());
333    }
334
335    #[test]
336    fn test_proxy_config_from_url_with_http_prefix() {
337        let proxy = ProxyConfig::from_url("http://proxy.example.com:3128").unwrap();
338        assert_eq!(proxy.host, "proxy.example.com");
339        assert_eq!(proxy.port, 3128);
340    }
341
342    #[test]
343    fn test_proxy_config_from_url_with_credentials() {
344        let proxy = ProxyConfig::from_url("http://user:pass@proxy.example.com:8080").unwrap();
345        assert_eq!(proxy.host, "proxy.example.com");
346        assert_eq!(proxy.port, 8080);
347        assert_eq!(proxy.username.as_deref(), Some("user"));
348        assert_eq!(proxy.password.as_deref(), Some("pass"));
349    }
350
351    #[test]
352    fn test_proxy_config_from_url_default_port() {
353        let proxy = ProxyConfig::from_url("proxy.example.com").unwrap();
354        assert_eq!(proxy.host, "proxy.example.com");
355        assert_eq!(proxy.port, 8080); // Default proxy port
356    }
357
358    #[test]
359    fn test_proxy_config_from_url_empty_returns_none() {
360        assert!(ProxyConfig::from_url("").is_none());
361    }
362
363    #[test]
364    fn test_proxy_config_builder() {
365        let proxy = ProxyConfig::new("proxy.local", 8888).with_credentials("admin", "secret");
366        assert_eq!(proxy.host, "proxy.local");
367        assert_eq!(proxy.port, 8888);
368        assert_eq!(proxy.username.as_deref(), Some("admin"));
369        assert_eq!(proxy.password.as_deref(), Some("secret"));
370    }
371
372    #[test]
373    fn test_proxy_password_redacted_in_debug() {
374        let proxy =
375            ProxyConfig::new("proxy.local", 8080).with_credentials("user", "secret_password");
376        let debug_output = format!("{:?}", proxy);
377        assert!(!debug_output.contains("secret_password"));
378        assert!(debug_output.contains("[REDACTED]"));
379    }
380
381    #[test]
382    fn test_ntrip_config_with_proxy() {
383        let proxy = ProxyConfig::new("proxy.local", 8080);
384        let config = NtripConfig::new("caster.example.com", 2101, "MOUNT").with_proxy(proxy);
385        assert!(config.proxy.is_some());
386        assert_eq!(config.proxy.as_ref().unwrap().host, "proxy.local");
387    }
388}