Skip to main content

gsc_fq/config/
loader.rs

1use serde::Deserialize;
2use std::fs;
3use std::net::{IpAddr, SocketAddr};
4use std::path::Path;
5
6use crate::error::{AppError, ConfigError, Result};
7
8/// TOML configuration file structure
9#[derive(Debug, Deserialize, Clone)]
10pub struct ConfigFile {
11    pub server: Option<ServerSection>,
12    #[serde(default)]
13    pub proxies: Vec<ProxySection>,
14    pub token: Option<String>,
15    pub totp_secret: Option<String>,
16    #[serde(default)]
17    pub reverse_proxies: Vec<ReverseProxySection>,
18
19    // Reverse proxy server configuration (optional)
20    pub reverse_proxy_server: Option<ReverseProxyServerSection>,
21
22    // Reverse proxy client configuration (optional)
23    pub reverse_proxy_client: Option<ReverseProxyClientSection>,
24}
25
26/// Server configuration section
27#[derive(Debug, Deserialize, Clone)]
28pub struct ServerSection {
29    pub bind_ip: Option<String>,
30    pub debug: Option<bool>,
31}
32
33impl ServerSection {
34    // Server section no longer handles authentication tokens
35    // Authentication for reverse proxy is now handled in reverse_proxy_server section
36}
37
38/// Authentication mode for server
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum AuthMode {
41    None,     // No authentication required
42    Single,   // Single token validation
43    Multiple, // Multiple allowed tokens
44}
45
46impl Default for ServerSection {
47    fn default() -> Self {
48        Self {
49            bind_ip: Some("127.0.0.1".to_string()),
50            debug: Some(false),
51        }
52    }
53}
54
55/// Proxy configuration section
56#[derive(Debug, Deserialize, Clone)]
57pub struct ProxySection {
58    pub local: String,  // "8080" or "127.0.0.1:8080"
59    pub remote: String, // "80" or "example.com:80" or "192.168.1.100:80"
60    pub source_ip: Option<String>,
61    pub allow_ips: Option<Vec<String>>,
62    pub max_conns_per_ip: Option<usize>,
63    pub cps_limit: Option<f64>, // connections per second
64}
65
66impl ProxySection {
67    /// Get local port
68    pub fn get_local_port(&self) -> Result<u16> {
69        if self.local.contains(':') {
70            // Format: "IP:PORT"
71            let parts: Vec<&str> = self.local.split(':').collect();
72            if parts.len() != 2 {
73                return Err(AppError::Config(ConfigError::InvalidConfigValue {
74                    path: "local".to_string(),
75                    reason: format!(
76                        "Invalid local format '{}', expected 'IP:PORT' or 'PORT'",
77                        self.local
78                    ),
79                }));
80            }
81            parts[1].parse().map_err(|_| {
82                AppError::Config(ConfigError::InvalidConfigValue {
83                    path: "local".to_string(),
84                    reason: format!("Invalid port number in '{}'", self.local),
85                })
86            })
87        } else {
88            // Format: "PORT"
89            self.local.parse().map_err(|_| {
90                AppError::Config(ConfigError::InvalidConfigValue {
91                    path: "local".to_string(),
92                    reason: format!("Invalid port number '{}'", self.local),
93                })
94            })
95        }
96    }
97
98    /// Get local IP (None if using wildcard)
99    pub fn get_local_ip(&self) -> Option<String> {
100        if self.local.contains(':') {
101            let parts: Vec<&str> = self.local.split(':').collect();
102            if parts.len() == 2 {
103                Some(parts[0].to_string())
104            } else {
105                None
106            }
107        } else {
108            None // Use default bind IP
109        }
110    }
111
112    /// Get remote host
113    pub fn get_remote_host(&self) -> Result<String> {
114        if self.remote.contains(':') {
115            // Format: "HOST:PORT"
116            let parts: Vec<&str> = self.remote.split(':').collect();
117            if parts.len() < 2 {
118                return Err(AppError::Config(ConfigError::InvalidConfigValue {
119                    path: "remote".to_string(),
120                    reason: format!(
121                        "Invalid remote format '{}', expected 'HOST:PORT' or 'PORT'",
122                        self.remote
123                    ),
124                }));
125            }
126            Ok(parts[0..parts.len() - 1].join(":")) // Handle IPv6 addresses
127        } else {
128            // Format: "PORT" - assume localhost
129            Ok("localhost".to_string())
130        }
131    }
132
133    /// Get remote port
134    pub fn get_remote_port(&self) -> Result<u16> {
135        if self.remote.contains(':') {
136            // Format: "HOST:PORT"
137            let parts: Vec<&str> = self.remote.rsplit(':').collect();
138            if parts.len() < 2 {
139                return Err(AppError::Config(ConfigError::InvalidConfigValue {
140                    path: "remote".to_string(),
141                    reason: format!("Invalid remote format '{}'", self.remote),
142                }));
143            }
144            parts[0].parse().map_err(|_| {
145                AppError::Config(ConfigError::InvalidConfigValue {
146                    path: "remote".to_string(),
147                    reason: format!("Invalid port number in '{}'", self.remote),
148                })
149            })
150        } else {
151            // Format: "PORT"
152            self.remote.parse().map_err(|_| {
153                AppError::Config(ConfigError::InvalidConfigValue {
154                    path: "remote".to_string(),
155                    reason: format!("Invalid port number '{}'", self.remote),
156                })
157            })
158        }
159    }
160}
161
162/// Reverse proxy configuration section
163#[derive(Debug, Deserialize, Clone)]
164pub struct ReverseProxySection {
165    // Server side: can be "7000" or "0.0.0.0:7000"
166    pub server: String,
167    // Local side: can be "8080" or "127.0.0.1:8080"
168    pub local: String,
169    pub source_ip: Option<String>,
170}
171
172impl ReverseProxySection {
173    /// Get server port
174    pub fn get_server_port(&self) -> Result<u16> {
175        if self.server.contains(':') {
176            // Format: "IP:PORT"
177            let parts: Vec<&str> = self.server.rsplit(':').collect();
178            if parts.len() < 2 {
179                return Err(AppError::Config(ConfigError::InvalidConfigValue {
180                    path: "server".to_string(),
181                    reason: format!("Invalid server format '{}'", self.server),
182                }));
183            }
184            parts[0].parse().map_err(|_| {
185                AppError::Config(ConfigError::InvalidConfigValue {
186                    path: "server".to_string(),
187                    reason: format!("Invalid port number in '{}'", self.server),
188                })
189            })
190        } else {
191            // Format: "PORT"
192            self.server.parse().map_err(|_| {
193                AppError::Config(ConfigError::InvalidConfigValue {
194                    path: "server".to_string(),
195                    reason: format!("Invalid port number '{}'", self.server),
196                })
197            })
198        }
199    }
200
201    /// Get server IP (None if using wildcard)
202    pub fn get_server_ip(&self) -> Option<String> {
203        if self.server.contains(':') {
204            let parts: Vec<&str> = self.server.split(':').collect();
205            if parts.len() >= 2 {
206                Some(parts[0..parts.len() - 1].join(":")) // Handle IPv6
207            } else {
208                None
209            }
210        } else {
211            None // Use default bind IP
212        }
213    }
214
215    /// Get local port
216    pub fn get_local_port(&self) -> Result<u16> {
217        if self.local.contains(':') {
218            // Format: "IP:PORT"
219            let parts: Vec<&str> = self.local.rsplit(':').collect();
220            if parts.len() < 2 {
221                return Err(AppError::Config(ConfigError::InvalidConfigValue {
222                    path: "local".to_string(),
223                    reason: format!("Invalid local format '{}'", self.local),
224                }));
225            }
226            parts[0].parse().map_err(|_| {
227                AppError::Config(ConfigError::InvalidConfigValue {
228                    path: "local".to_string(),
229                    reason: format!("Invalid port number in '{}'", self.local),
230                })
231            })
232        } else {
233            // Format: "PORT"
234            self.local.parse().map_err(|_| {
235                AppError::Config(ConfigError::InvalidConfigValue {
236                    path: "local".to_string(),
237                    reason: format!("Invalid port number '{}'", self.local),
238                })
239            })
240        }
241    }
242
243    /// Get local host (None if using localhost)
244    pub fn get_local_host(&self) -> Option<String> {
245        if self.local.contains(':') {
246            let parts: Vec<&str> = self.local.split(':').collect();
247            if parts.len() >= 2 {
248                Some(parts[0..parts.len() - 1].join(":")) // Handle IPv6
249            } else {
250                Some("localhost".to_string())
251            }
252        } else {
253            Some("localhost".to_string()) // Use localhost by default
254        }
255    }
256}
257
258/// Reverse proxy server configuration
259#[derive(Debug, Deserialize, Clone)]
260pub struct ReverseProxyServerSection {
261    /// Port for the reverse proxy server to listen on
262    pub port: u16,
263    #[serde(default)]
264    pub allowed_tokens: Vec<String>, // Authentication tokens for reverse proxy clients
265    pub totp_secret: Option<String>, // Base32 or Hex secret for TOTP
266}
267
268impl Default for ReverseProxyServerSection {
269    fn default() -> Self {
270        Self {
271            port: 9001,
272            allowed_tokens: Vec::new(),
273            totp_secret: None,
274        }
275    }
276}
277
278/// Reverse proxy client configuration
279#[derive(Debug, Deserialize, Clone)]
280pub struct ReverseProxyClientSection {
281    /// Server address to connect to (e.g., "server.example.com:9001")
282    pub server: String,
283    pub token: Option<String>,
284    pub totp_secret: Option<String>,
285}
286
287impl ConfigFile {
288    /// Determine runtime mode from configuration
289    /// 注意:此方法仅用于日志显示,实际运行时会根据配置启动所有服务
290    pub fn get_runtime_mode(&self) -> String {
291        let has_forward = !self.proxies.is_empty();
292        let has_reverse_server = self.reverse_proxy_server.is_some();
293        let has_reverse_client = self.reverse_proxy_client.is_some();
294
295        // 根据优先级显示主模式(用于日志)
296        if has_reverse_client {
297            "reverse_client".to_string()
298        } else if has_reverse_server {
299            "reverse_server".to_string()
300        } else if has_forward {
301            "forward".to_string()
302        } else {
303            "unknown".to_string()
304        }
305    }
306
307    /// Validate configuration integrity and return non-fatal warnings
308    pub fn validate(&mut self) -> std::result::Result<Vec<String>, ConfigError> {
309        use std::collections::HashSet;
310
311        let mut warnings = Vec::new();
312        let mut errors = Vec::new();
313
314        // 验证反向代理配置的一致性
315        if !self.reverse_proxies.is_empty() {
316            let has_server = self.reverse_proxy_server.is_some();
317            let has_client = self.reverse_proxy_client.is_some();
318
319            if !has_server && !has_client {
320                errors.push("Reverse proxies configured but neither reverse_proxy_server nor reverse_proxy_client specified".to_string());
321            }
322        }
323
324        if let Some(server) = self.server.as_mut() {
325            if let Some(current) = server.bind_ip.clone() {
326                let trimmed = current.trim();
327                if trimmed.is_empty() {
328                    warnings.push(
329                        "server.bind_ip is empty, falling back to default 127.0.0.1".to_string(),
330                    );
331                    server.bind_ip = Some("127.0.0.1".to_string());
332                } else if trimmed.parse::<IpAddr>().is_err() {
333                    errors.push(format!(
334                        "server.bind_ip '{}' is not a valid IP address",
335                        trimmed
336                    ));
337                } else if trimmed != current {
338                    server.bind_ip = Some(trimmed.to_string());
339                }
340            }
341        }
342
343        let mut local_ports = HashSet::new();
344
345        // Validate proxies (forward proxy)
346        for (index, proxy) in self.proxies.iter_mut().enumerate() {
347            let prefix = format!("proxies[{}]", index);
348
349            // Validate local format
350            let local_port = match proxy.get_local_port() {
351                Ok(port) => port,
352                Err(e) => {
353                    errors.push(format!("{}.local: {}", prefix, e));
354                    continue;
355                }
356            };
357
358            if local_port == 0 {
359                errors.push(format!("{}.local port must be between 1 and 65535", prefix));
360            }
361
362            // Validate remote format
363            let _remote_host = match proxy.get_remote_host() {
364                Ok(host) => host,
365                Err(e) => {
366                    errors.push(format!("{}.remote: {}", prefix, e));
367                    continue;
368                }
369            };
370
371            let remote_port = match proxy.get_remote_port() {
372                Ok(port) => port,
373                Err(e) => {
374                    errors.push(format!("{}.remote: {}", prefix, e));
375                    continue;
376                }
377            };
378
379            if remote_port == 0 {
380                errors.push(format!(
381                    "{}.remote port must be between 1 and 65535",
382                    prefix
383                ));
384            }
385
386            // Check for duplicate local ports
387            if !local_ports.insert(local_port) {
388                errors.push(format!(
389                    "Duplicate local port {} detected in {}",
390                    local_port, prefix
391                ));
392            }
393
394            // Validate and sanitize source_ip if present
395            if let Some(ref source_ip) = proxy.source_ip {
396                let trimmed = source_ip.trim();
397
398                if trimmed.is_empty() {
399                    warnings.push(format!("{}.source_ip is empty and will be ignored", prefix));
400                    proxy.source_ip = None;
401                } else if trimmed.eq_ignore_ascii_case("null") {
402                    warnings.push(format!(
403                        "{}.source_ip contains 'null' value; will be ignored",
404                        prefix
405                    ));
406                    proxy.source_ip = None;
407                } else if trimmed.parse::<IpAddr>().is_err() {
408                    errors.push(format!(
409                        "{}.source_ip '{}' is not a valid IP address",
410                        prefix, trimmed
411                    ));
412                }
413            }
414        }
415
416        // Validate reverse_proxies
417        let mut server_ports = HashSet::new();
418
419        for (index, rproxy) in self.reverse_proxies.iter().enumerate() {
420            let prefix = format!("reverse_proxies[{}]", index);
421
422            // Validate server format
423            let server_port = match rproxy.get_server_port() {
424                Ok(port) => port,
425                Err(e) => {
426                    errors.push(format!("{}.server: {}", prefix, e));
427                    continue;
428                }
429            };
430
431            if server_port == 0 {
432                errors.push(format!(
433                    "{}.server port must be between 1 and 65535",
434                    prefix
435                ));
436            }
437
438            // Check for duplicate server ports
439            if !server_ports.insert(server_port) {
440                errors.push(format!(
441                    "Duplicate server port {} detected in {}",
442                    server_port, prefix
443                ));
444            }
445
446            // Validate local format
447            let local_port = match rproxy.get_local_port() {
448                Ok(port) => port,
449                Err(e) => {
450                    errors.push(format!("{}.local: {}", prefix, e));
451                    continue;
452                }
453            };
454
455            if local_port == 0 {
456                errors.push(format!("{}.local port must be between 1 and 65535", prefix));
457            }
458
459            // Validate source_ip if present
460            if let Some(ref source_ip) = rproxy.source_ip {
461                let trimmed = source_ip.trim();
462
463                if trimmed.is_empty() {
464                    warnings.push(format!("{}.source_ip is empty and will be ignored", prefix));
465                } else if trimmed.eq_ignore_ascii_case("null") {
466                    warnings.push(format!(
467                        "{}.source_ip contains 'null' value; will be ignored",
468                        prefix
469                    ));
470                } else if trimmed.parse::<IpAddr>().is_err() {
471                    errors.push(format!(
472                        "{}.source_ip '{}' is not a valid IP address",
473                        prefix, trimmed
474                    ));
475                }
476            }
477        }
478
479        if !errors.is_empty() {
480            return Err(ConfigError::InvalidConfigValue {
481                path: "config".to_string(),
482                reason: errors.join("; "),
483            });
484        }
485
486        Ok(warnings)
487    }
488}
489
490/// Configuration loader
491pub struct ConfigLoader;
492
493impl ConfigLoader {
494    /// Load configuration from file
495    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<ConfigFile> {
496        let path_ref = path.as_ref();
497        let content = fs::read_to_string(path_ref).map_err(|e| {
498            if e.kind() == std::io::ErrorKind::NotFound {
499                AppError::Config(ConfigError::ConfigFileNotFound(
500                    path_ref.to_string_lossy().to_string(),
501                ))
502            } else {
503                AppError::Config(ConfigError::ReadFailed(format!(
504                    "Failed to read config file '{}': {}",
505                    path_ref.display(),
506                    e
507                )))
508            }
509        })?;
510
511        let (config, warnings) = Self::load_from_str_with_warnings(&content)?;
512        Self::emit_warnings(&warnings);
513        Ok(config)
514    }
515
516    /// Load configuration from string
517    pub fn load_from_str(content: &str) -> Result<ConfigFile> {
518        let (config, warnings) =
519            Self::load_from_str_with_warnings(content).map_err(AppError::from)?;
520        Self::emit_warnings(&warnings);
521        Ok(config)
522    }
523
524    /// Load configuration from string and capture validation warnings
525    fn load_from_str_with_warnings(
526        content: &str,
527    ) -> std::result::Result<(ConfigFile, Vec<String>), ConfigError> {
528        let (sanitized, mut warnings) = Self::sanitize_special_values(content);
529
530        let mut config = toml::from_str::<ConfigFile>(&sanitized)
531            .map_err(|err| Self::map_toml_error(&sanitized, err))?;
532        let mut validation_warnings = config.validate()?;
533        warnings.append(&mut validation_warnings);
534
535        Ok((config, warnings))
536    }
537
538    fn emit_warnings(warnings: &[String]) {
539        for warning in warnings {
540            eprintln!("⚠️  Configuration Warning: {}", warning);
541        }
542    }
543
544    /// Parse IP address
545    pub fn parse_ip_address(ip_str: &str) -> Result<IpAddr> {
546        let trimmed = ip_str.trim();
547        Ok(trimmed
548            .parse::<IpAddr>()
549            .map_err(|_| ConfigError::InvalidIpAddress(trimmed.to_string()))?)
550    }
551
552    /// Create socket address - ONLY accepts IP addresses, no DNS resolution
553    /// Tunnel proxy should only use IP addresses directly
554    pub fn create_socket_addr(host: &str, port: u16) -> Result<SocketAddr> {
555        // Only parse as IP address - no DNS resolution allowed
556        host.parse::<IpAddr>()
557            .map(|ip| SocketAddr::new(ip, port))
558            .map_err(|_| ConfigError::InvalidIpAddress(format!(
559                "Invalid IP address '{}'. Tunnel proxy requires IP addresses, not hostnames. Use nslookup or dig to resolve hostnames manually.",
560                host
561            )))
562            .map_err(AppError::Config)
563    }
564
565    /// Check if configuration file exists
566    pub fn config_file_exists<P: AsRef<Path>>(path: P) -> bool {
567        path.as_ref().exists()
568    }
569
570    fn sanitize_special_values(content: &str) -> (String, Vec<String>) {
571        let mut sanitized = String::with_capacity(content.len());
572        let warnings = Vec::new();
573
574        for raw_line in content.split_inclusive('\n') {
575            let (line_body, newline) = match raw_line.strip_suffix('\n') {
576                Some(body) => (body, "\n"),
577                None => (raw_line, ""),
578            };
579
580            let mut replaced_line = line_body.to_string();
581
582            if let Some(eq_index) = line_body.find('=') {
583                let key_part = &line_body[..eq_index];
584                if key_part.trim().eq_ignore_ascii_case("source_ip") {
585                    let prefix = &line_body[..=eq_index];
586                    let rest = &line_body[eq_index + 1..];
587                    let trimmed_rest = rest.trim_start();
588
589                    if trimmed_rest.len() >= 4 && trimmed_rest[..4].eq_ignore_ascii_case("null") {
590                        let remainder = &trimmed_rest[4..];
591                        if remainder.is_empty()
592                            || remainder.starts_with(|c: char| c.is_whitespace() || c == '#')
593                        {
594                            let whitespace_len = rest.len() - trimmed_rest.len();
595                            let whitespace = &rest[..whitespace_len];
596                            let suffix = &rest[whitespace_len + 4..];
597                            replaced_line = format!("{}{}\"null\"{}", prefix, whitespace, suffix);
598                        }
599                    }
600                }
601            }
602
603            sanitized.push_str(&replaced_line);
604            sanitized.push_str(newline);
605        }
606
607        if !content.ends_with('\n') && sanitized.ends_with('\n') {
608            sanitized.pop();
609        }
610
611        (sanitized, warnings)
612    }
613
614    fn map_toml_error(content: &str, error: toml::de::Error) -> ConfigError {
615        let message = error.message().to_string();
616
617        if let Some(field) = message.strip_prefix("missing field `") {
618            if let Some(end) = field.find('`') {
619                let field_name = &field[..end];
620                return ConfigError::MissingRequiredField(field_name.to_string());
621            }
622        }
623
624        let (line, column) = error
625            .span()
626            .map(|span| Self::offset_to_line_col(content, span.start))
627            .unwrap_or((0, 0));
628        ConfigError::InvalidTomlFormat(format!(
629            "TOML parse error at line {}, column {}: {}\n\
630             Tip: Check for syntax errors like 'null' values (should be omitted), \
631             missing quotes, or invalid data types",
632            line, column, message
633        ))
634    }
635
636    fn offset_to_line_col(content: &str, offset: usize) -> (usize, usize) {
637        let mut line = 1usize;
638        let mut column = 1usize;
639        let upto = offset.min(content.len());
640
641        for byte in &content.as_bytes()[..upto] {
642            match byte {
643                b'\n' => {
644                    line += 1;
645                    column = 1;
646                }
647                b'\r' => column = 1,
648                _ => column += 1,
649            }
650        }
651
652        (line, column)
653    }
654
655    /// Load configuration by searching CLI arguments or common paths
656    pub fn load_with_search() -> Result<(ConfigFile, std::path::PathBuf)> {
657        // 1. Check CLI args first
658        if let Some(path) = Self::get_cli_config_path() {
659            if path.exists() {
660                match Self::load_from_file(&path) {
661                    Ok(config) => return Ok((config, path)),
662                    Err(e) => {
663                        eprintln!(
664                            "❌ Failed to load config specified by CLI '{:?}': {}",
665                            path, e
666                        );
667                        // If user explicitly provided a path and it fails, we should probably fail hard?
668                        // But existing logic might prefer fallback. For now, let's return error to be explicit.
669                        return Err(e);
670                    }
671                }
672            } else {
673                return Err(AppError::Config(ConfigError::ConfigFileNotFound(format!(
674                    "CLI specified config file not found: {:?}",
675                    path
676                ))));
677            }
678        }
679
680        // 2. Fallback to default search paths
681        let paths = Self::get_config_search_paths();
682
683        for path in &paths {
684            if path.exists() {
685                match Self::load_from_file(path) {
686                    Ok(config) => return Ok((config, path.clone())),
687                    Err(e) => eprintln!("⚠️  Failed to load config from {}: {}", path.display(), e),
688                }
689            }
690        }
691
692        Err(AppError::Config(ConfigError::ConfigFileNotFound(format!(
693            "No configuration file found. Searched: {:?}",
694            paths
695        ))))
696    }
697
698    /// Check for `-c` or `--config` argument
699    fn get_cli_config_path() -> Option<std::path::PathBuf> {
700        let args: Vec<String> = std::env::args().collect();
701        for i in 1..args.len() {
702            if (args[i] == "-c" || args[i] == "--config") && i + 1 < args.len() {
703                return Some(std::path::PathBuf::from(&args[i + 1]));
704            }
705        }
706        None
707    }
708
709    /// Get list of configuration search paths (defaults)
710    pub fn get_config_search_paths() -> Vec<std::path::PathBuf> {
711        let mut paths = Vec::new();
712        paths.push(std::path::PathBuf::from("default.toml"));
713        paths.push(std::path::PathBuf::from("config.toml"));
714        paths.push(std::path::PathBuf::from("gsc-fq.toml"));
715        paths
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use std::fs;
723    use std::net::{Ipv4Addr, Ipv6Addr};
724    use tempfile::NamedTempFile;
725
726    #[test]
727    fn test_validate_detects_invalid_source_ip() {
728        let mut config = ConfigFile {
729            server: None,
730            proxies: vec![ProxySection {
731                local: "8080".to_string(),
732                remote: "example.com:80".to_string(),
733                source_ip: Some("invalid-ip".to_string()),
734                allow_ips: None,
735                max_conns_per_ip: None,
736                cps_limit: None,
737            }],
738            token: Some("default".to_string()),
739            totp_secret: None,
740            reverse_proxies: vec![],
741            reverse_proxy_server: None,
742            reverse_proxy_client: None,
743        };
744
745        let result = config.validate();
746        assert!(result.is_err());
747        if let Err(ConfigError::InvalidConfigValue { reason, .. }) = result {
748            assert!(reason.contains("not a valid IP address"));
749        } else {
750            panic!("Expected InvalidConfigValue error");
751        }
752    }
753
754    #[test]
755    fn test_validate_handles_null_source_ip() {
756        let content = r#"
757[[proxies]]
758local = "9000"
759remote = "example.com:443"
760source_ip = "null"
761"#;
762
763        let (config, warnings) =
764            ConfigLoader::load_from_str_with_warnings(content).expect("Should load config");
765
766        assert!(warnings
767            .iter()
768            .any(|w| w.contains("source_ip") && w.contains("null")));
769        assert!(config.proxies[0].source_ip.is_none());
770    }
771
772    #[test]
773    fn test_sanitize_unquoted_null_source_ip() {
774        let content = r#"
775[[proxies]]
776local = "8000"
777remote = "example.org:80"
778source_ip = null
779"#;
780
781        let (config, warnings) =
782            ConfigLoader::load_from_str_with_warnings(content).expect("Should load config");
783
784        assert!(warnings
785            .iter()
786            .any(|w| w.contains("source_ip") && w.contains("null")));
787        assert!(config.proxies[0].source_ip.is_none());
788    }
789
790    #[test]
791    fn test_missing_required_field_error() {
792        let content = r#"
793[[proxies]]
794local = "7000"
795# Missing remote field on purpose
796"#;
797
798        let err = ConfigLoader::load_from_str_with_warnings(content).unwrap_err();
799        match err {
800            ConfigError::MissingRequiredField(field) => {
801                assert_eq!(field, "remote");
802            }
803            other => panic!("Expected MissingRequiredField, got {:?}", other),
804        }
805    }
806
807    #[test]
808    fn test_duplicate_local_port_detection() {
809        let mut config = ConfigFile {
810            server: None,
811            proxies: vec![
812                ProxySection {
813                    local: "8080".to_string(),
814                    remote: "example.com:80".to_string(),
815                    source_ip: None,
816                    allow_ips: None,
817                    max_conns_per_ip: None,
818                    cps_limit: None,
819                },
820                ProxySection {
821                    local: "8080".to_string(),
822                    remote: "example.net:8080".to_string(),
823                    source_ip: None,
824                    allow_ips: None,
825                    max_conns_per_ip: None,
826                    cps_limit: None,
827                },
828            ],
829            token: Some("default".to_string()),
830            totp_secret: None,
831            reverse_proxies: vec![],
832            reverse_proxy_server: None,
833            reverse_proxy_client: None,
834        };
835
836        let result = config.validate();
837        assert!(result.is_err());
838        if let Err(ConfigError::InvalidConfigValue { reason, .. }) = result {
839            assert!(reason.contains("Duplicate local port"));
840        } else {
841            panic!("Expected InvalidConfigValue error for duplicate port");
842        }
843    }
844
845    #[test]
846    fn test_config_file_not_found() {
847        let err = ConfigLoader::load_from_file("does_not_exist.toml").unwrap_err();
848        match err {
849            AppError::Config(ConfigError::ConfigFileNotFound(path)) => {
850                assert!(path.contains("does_not_exist.toml"));
851            }
852            other => panic!("Expected ConfigFileNotFound, got {:?}", other),
853        }
854    }
855
856    #[test]
857    fn test_read_and_validate_from_file() {
858        let file = NamedTempFile::new().expect("create temp file");
859        let content = r#"
860[[proxies]]
861local = "8100"
862remote = "example.com:8101"
863source_ip = null
864"#;
865        fs::write(file.path(), content).expect("write config");
866
867        let config = ConfigLoader::load_from_file(file.path()).expect("load config");
868        assert!(config.proxies[0].source_ip.is_none());
869    }
870
871    #[test]
872    fn test_server_bind_ip_empty_defaults() {
873        let content = r#"[server]
874bind_ip = ""
875allowed_tokens = []
876
877[[proxies]]
878local = "8200"
879remote = "example.com:8201"
880"#;
881
882        let (config, warnings) =
883            ConfigLoader::load_from_str_with_warnings(content).expect("load config");
884
885        assert!(warnings.iter().any(|w| w.contains("bind_ip")));
886        let server = config.server.as_ref().expect("server section");
887        assert_eq!(server.bind_ip.as_deref(), Some("127.0.0.1"));
888    }
889
890    #[test]
891    fn test_ip_parsing() {
892        let ipv4 = ConfigLoader::parse_ip_address("192.168.1.1").unwrap();
893        assert_eq!(ipv4, IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)));
894
895        let ipv6 = ConfigLoader::parse_ip_address("::1").unwrap();
896        assert_eq!(ipv6, IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)));
897
898        let trimmed = ConfigLoader::parse_ip_address(" 127.0.0.1 ").unwrap();
899        assert_eq!(trimmed, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
900
901        let invalid = ConfigLoader::parse_ip_address("invalid");
902        assert!(invalid.is_err());
903    }
904
905    #[test]
906    fn test_load_from_str() {
907        let toml_content = r#"[server]
908bind_ip = " 127.0.0.1 "
909allowed_tokens = []
910
911[[proxies]]
912local = "8080"
913remote = "example.com:80"
914"#;
915
916        let (config, warnings) =
917            ConfigLoader::load_from_str_with_warnings(toml_content).expect("load config");
918        assert!(warnings.is_empty());
919
920        let server = config.server.as_ref().expect("Server section should exist");
921        assert_eq!(server.bind_ip.as_deref(), Some("127.0.0.1"));
922        assert_eq!(config.proxies.len(), 1);
923        assert_eq!(config.proxies[0].local, "8080");
924        assert_eq!(config.proxies[0].remote, "example.com:80");
925    }
926}