bws_web_server/config/
site.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::Path;
4
5#[derive(Debug, Deserialize, Serialize, Clone)]
6pub struct SiteConfig {
7    pub name: String,
8    pub hostname: String,
9    #[serde(default)]
10    pub hostnames: Vec<String>, // Additional hostnames that share the same port and config
11    pub port: u16,
12    pub static_dir: String,
13    #[serde(default)]
14    pub default: bool,
15    #[serde(default)]
16    pub api_only: bool,
17    #[serde(default)]
18    pub headers: HashMap<String, String>,
19    #[serde(default)]
20    pub redirect_to_https: bool,
21    #[serde(default)]
22    pub index_files: Vec<String>,
23    #[serde(default)]
24    pub error_pages: HashMap<u16, String>,
25    #[serde(default)]
26    pub compression: CompressionConfig,
27    #[serde(default)]
28    pub cache: CacheConfig,
29    #[serde(default)]
30    pub access_control: AccessControlConfig,
31    #[serde(default)]
32    pub ssl: SiteSslConfig,
33    #[serde(default)]
34    pub proxy: ProxyConfig,
35}
36
37#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq)]
38pub struct SiteSslConfig {
39    #[serde(default)]
40    pub enabled: bool,
41    #[serde(default)]
42    pub auto_cert: bool,
43    #[serde(default)]
44    pub domains: Vec<String>, // Additional domains beyond hostname
45    #[serde(default)]
46    pub cert_file: Option<String>, // Manual certificate file
47    #[serde(default)]
48    pub key_file: Option<String>, // Manual key file
49    #[serde(default)]
50    pub acme: Option<SiteAcmeConfig>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
54pub struct SiteAcmeConfig {
55    #[serde(default)]
56    pub enabled: bool,
57    #[serde(default)]
58    pub email: String,
59    #[serde(default)]
60    pub staging: bool,
61    #[serde(default)]
62    pub challenge_dir: Option<String>, // Make optional for automatic management
63}
64
65#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq)]
66pub struct ProxyConfig {
67    #[serde(default)]
68    pub enabled: bool,
69    #[serde(default)]
70    pub upstreams: Vec<UpstreamConfig>,
71    #[serde(default)]
72    pub routes: Vec<ProxyRoute>,
73    #[serde(default)]
74    pub health_check: HealthCheckConfig,
75    #[serde(default)]
76    pub load_balancing: LoadBalancingConfig,
77    #[serde(default)]
78    pub timeout: TimeoutConfig,
79    #[serde(default)]
80    pub headers: ProxyHeadersConfig,
81}
82
83#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
84pub struct UpstreamConfig {
85    pub name: String,
86    pub url: String,
87    #[serde(default = "default_weight")]
88    pub weight: u32,
89    #[serde(default)]
90    pub max_conns: Option<u32>,
91}
92
93#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
94pub struct ProxyRoute {
95    pub path: String,
96    pub upstream: String, // References upstream name
97    #[serde(default)]
98    pub strip_prefix: bool,
99    #[serde(default)]
100    pub rewrite_target: Option<String>,
101    #[serde(default)]
102    pub websocket: bool, // Enable WebSocket proxying for this route
103}
104
105#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
106pub struct HealthCheckConfig {
107    #[serde(default)]
108    pub enabled: bool,
109    #[serde(default = "default_health_path")]
110    pub path: String,
111    #[serde(default = "default_health_interval")]
112    pub interval: u64, // seconds
113    #[serde(default = "default_health_timeout")]
114    pub timeout: u64, // seconds
115    #[serde(default = "default_health_retries")]
116    pub retries: u32,
117}
118
119#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
120pub struct LoadBalancingConfig {
121    #[serde(default = "default_lb_method")]
122    pub method: String, // "round_robin", "least_conn", "weighted"
123    #[serde(default)]
124    pub sticky_sessions: bool,
125}
126
127#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
128pub struct TimeoutConfig {
129    #[serde(default = "default_connect_timeout")]
130    pub connect: u64, // seconds
131    #[serde(default = "default_read_timeout")]
132    pub read: u64, // seconds
133    #[serde(default = "default_write_timeout")]
134    pub write: u64, // seconds
135}
136
137#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
138pub struct ProxyHeadersConfig {
139    #[serde(default)]
140    pub preserve_host: bool,
141    #[serde(default)]
142    pub add_forwarded: bool,
143    #[serde(default)]
144    pub add_x_forwarded: bool,
145    #[serde(default)]
146    pub remove: Vec<String>,
147    #[serde(default)]
148    pub add: HashMap<String, String>,
149}
150
151fn default_weight() -> u32 {
152    1
153}
154fn default_health_path() -> String {
155    "/health".to_string()
156}
157fn default_health_interval() -> u64 {
158    30
159}
160fn default_health_timeout() -> u64 {
161    5
162}
163fn default_health_retries() -> u32 {
164    3
165}
166fn default_lb_method() -> String {
167    "round_robin".to_string()
168}
169fn default_connect_timeout() -> u64 {
170    10
171}
172fn default_read_timeout() -> u64 {
173    30
174}
175fn default_write_timeout() -> u64 {
176    30
177}
178
179impl Default for HealthCheckConfig {
180    fn default() -> Self {
181        Self {
182            enabled: false,
183            path: default_health_path(),
184            interval: default_health_interval(),
185            timeout: default_health_timeout(),
186            retries: default_health_retries(),
187        }
188    }
189}
190
191impl Default for LoadBalancingConfig {
192    fn default() -> Self {
193        Self {
194            method: default_lb_method(),
195            sticky_sessions: false,
196        }
197    }
198}
199
200impl Default for TimeoutConfig {
201    fn default() -> Self {
202        Self {
203            connect: default_connect_timeout(),
204            read: default_read_timeout(),
205            write: default_write_timeout(),
206        }
207    }
208}
209
210impl Default for ProxyHeadersConfig {
211    fn default() -> Self {
212        Self {
213            preserve_host: true,
214            add_forwarded: true,
215            add_x_forwarded: true,
216            remove: Vec::new(),
217            add: HashMap::new(),
218        }
219    }
220}
221
222#[derive(Debug, Deserialize, Serialize, Clone)]
223pub struct CompressionConfig {
224    #[serde(default)]
225    pub enabled: bool,
226    #[serde(default = "default_compression_types")]
227    pub types: Vec<String>,
228    #[serde(default = "default_compression_level")]
229    pub level: u32,
230    #[serde(default = "default_min_size")]
231    pub min_size: usize,
232}
233
234#[derive(Debug, Deserialize, Serialize, Clone)]
235pub struct CacheConfig {
236    #[serde(default)]
237    pub enabled: bool,
238    #[serde(default = "default_cache_control")]
239    pub cache_control: String,
240    #[serde(default)]
241    pub etag_enabled: bool,
242    #[serde(default)]
243    pub last_modified_enabled: bool,
244    #[serde(default)]
245    pub max_age_static: u32,
246    #[serde(default)]
247    pub max_age_dynamic: u32,
248}
249
250#[derive(Debug, Deserialize, Serialize, Clone)]
251pub struct AccessControlConfig {
252    #[serde(default)]
253    pub allow_methods: Vec<String>,
254    #[serde(default)]
255    pub allow_headers: Vec<String>,
256    #[serde(default)]
257    pub allow_origins: Vec<String>,
258    #[serde(default)]
259    pub allow_credentials: bool,
260    #[serde(default)]
261    pub max_age: u32,
262}
263
264// Default value functions
265fn default_compression_types() -> Vec<String> {
266    vec![
267        "text/html".to_string(),
268        "text/css".to_string(),
269        "text/javascript".to_string(),
270        "application/javascript".to_string(),
271        "application/json".to_string(),
272        "text/xml".to_string(),
273        "application/xml".to_string(),
274        "text/plain".to_string(),
275    ]
276}
277
278fn default_compression_level() -> u32 {
279    6
280}
281
282fn default_min_size() -> usize {
283    1024 // 1KB
284}
285
286fn default_cache_control() -> String {
287    "public, max-age=3600".to_string()
288}
289
290impl Default for CompressionConfig {
291    fn default() -> Self {
292        Self {
293            enabled: true,
294            types: default_compression_types(),
295            level: default_compression_level(),
296            min_size: default_min_size(),
297        }
298    }
299}
300
301impl Default for CacheConfig {
302    fn default() -> Self {
303        Self {
304            enabled: true,
305            cache_control: default_cache_control(),
306            etag_enabled: true,
307            last_modified_enabled: true,
308            max_age_static: 3600, // 1 hour for static files
309            max_age_dynamic: 300, // 5 minutes for dynamic content
310        }
311    }
312}
313
314impl Default for AccessControlConfig {
315    fn default() -> Self {
316        Self {
317            allow_methods: vec!["GET".to_string(), "HEAD".to_string(), "OPTIONS".to_string()],
318            allow_headers: vec!["Content-Type".to_string(), "Authorization".to_string()],
319            allow_origins: vec!["*".to_string()],
320            allow_credentials: false,
321            max_age: 86400, // 24 hours
322        }
323    }
324}
325
326impl SiteConfig {
327    pub fn validate(&self) -> Result<(), Box<dyn std::error::Error>> {
328        // Validate required fields
329        if self.name.is_empty() {
330            return Err("Site name cannot be empty".into());
331        }
332
333        if self.hostname.is_empty() {
334            return Err("Site hostname cannot be empty".into());
335        }
336
337        if self.port == 0 {
338            return Err("Site port must be greater than 0".into());
339        }
340
341        if self.static_dir.is_empty() {
342            return Err("Site static_dir cannot be empty".into());
343        }
344
345        // Validate static directory exists (or can be created)
346        let static_path = Path::new(&self.static_dir);
347        if !static_path.exists() {
348            log::warn!(
349                "Static directory does not exist for site '{}': {}",
350                self.name,
351                self.static_dir
352            );
353        }
354
355        // Validate hostname format (basic check)
356        if !self.is_valid_hostname() {
357            return Err(format!("Invalid hostname format: {}", self.hostname).into());
358        }
359
360        // Validate additional hostnames
361        for (i, hostname) in self.hostnames.iter().enumerate() {
362            if hostname.is_empty() {
363                return Err(format!("Additional hostname {} cannot be empty", i + 1).into());
364            }
365            if !self.is_hostname_valid(hostname) {
366                return Err(format!("Invalid additional hostname format: {}", hostname).into());
367            }
368        }
369
370        // Validate port range
371        if self.port < 1 {
372            return Err(format!("Invalid port number: {}", self.port).into());
373        }
374
375        // Validate SSL configuration
376        if self.ssl.enabled {
377            if self.ssl.auto_cert {
378                if let Some(acme) = &self.ssl.acme {
379                    if acme.email.is_empty() {
380                        return Err("ACME email is required when auto_cert is enabled".into());
381                    }
382                } else {
383                    return Err("ACME configuration is required when auto_cert is enabled".into());
384                }
385            } else if self.ssl.cert_file.is_none() || self.ssl.key_file.is_none() {
386                return Err("Manual SSL requires both cert_file and key_file".into());
387            }
388        }
389
390        // Validate index files
391        for index_file in &self.index_files {
392            if index_file.is_empty() {
393                return Err("Index file name cannot be empty".into());
394            }
395        }
396
397        // Validate error pages
398        for (status_code, error_page) in &self.error_pages {
399            if *status_code < 100 || *status_code > 999 {
400                return Err(format!("Invalid HTTP status code: {}", status_code).into());
401            }
402            if error_page.is_empty() {
403                return Err(
404                    format!("Error page path cannot be empty for status {}", status_code).into(),
405                );
406            }
407        }
408
409        // Validate compression configuration
410        self.compression.validate()?;
411
412        // Validate cache configuration
413        self.cache.validate()?;
414
415        // Validate access control configuration
416        self.access_control.validate()?;
417
418        Ok(())
419    }
420
421    fn is_valid_hostname(&self) -> bool {
422        self.is_hostname_valid(&self.hostname)
423    }
424
425    fn is_hostname_valid(&self, hostname: &str) -> bool {
426        // Basic hostname validation
427        if hostname.is_empty() || hostname.len() > 253 {
428            return false;
429        }
430
431        // Allow localhost and IP addresses for development
432        if hostname == "localhost"
433            || hostname.starts_with("127.")
434            || hostname.starts_with("0.0.0.0")
435        {
436            return true;
437        }
438
439        // Basic domain name validation
440        hostname.split('.').all(|label| {
441            !label.is_empty()
442                && label.len() <= 63
443                && label.chars().all(|c| c.is_alphanumeric() || c == '-')
444                && !label.starts_with('-')
445                && !label.ends_with('-')
446        })
447    }
448
449    pub fn get_ssl_domain(&self) -> Option<&str> {
450        if self.ssl.enabled {
451            // Return primary domain (hostname) plus any additional domains
452            Some(&self.hostname)
453        } else {
454            None
455        }
456    }
457
458    pub fn get_all_ssl_domains(&self) -> Vec<&str> {
459        if self.ssl.enabled {
460            let mut domains = vec![self.hostname.as_str()];
461            // Add additional hostnames
462            domains.extend(self.hostnames.iter().map(|h| h.as_str()));
463            // Add explicit SSL domains from configuration
464            domains.extend(self.ssl.domains.iter().map(|h| h.as_str()));
465            domains
466        } else {
467            Vec::new()
468        }
469    }
470
471    pub fn is_ssl_enabled(&self) -> bool {
472        self.ssl.enabled
473    }
474
475    pub fn get_index_files(&self) -> Vec<&str> {
476        if self.index_files.is_empty() {
477            vec!["index.html", "index.htm"]
478        } else {
479            self.index_files.iter().map(|s| s.as_str()).collect()
480        }
481    }
482
483    pub fn get_error_page(&self, status_code: u16) -> Option<&str> {
484        self.error_pages.get(&status_code).map(|s| s.as_str())
485    }
486
487    pub fn should_compress(&self, content_type: &str, content_length: usize) -> bool {
488        self.compression.enabled
489            && content_length >= self.compression.min_size
490            && self
491                .compression
492                .types
493                .iter()
494                .any(|t| content_type.starts_with(t))
495    }
496
497    pub fn get_cache_headers(&self, is_static: bool) -> Vec<(String, String)> {
498        let mut headers = Vec::new();
499
500        if self.cache.enabled {
501            let max_age = if is_static {
502                self.cache.max_age_static
503            } else {
504                self.cache.max_age_dynamic
505            };
506
507            headers.push((
508                "Cache-Control".to_string(),
509                format!("public, max-age={}", max_age),
510            ));
511
512            if self.cache.etag_enabled {
513                // ETag would be calculated based on file content
514                // This is a placeholder - actual implementation would calculate based on file
515                headers.push(("ETag".to_string(), "\"placeholder\"".to_string()));
516            }
517
518            if self.cache.last_modified_enabled {
519                // Last-Modified would be based on file modification time
520                // This is a placeholder - actual implementation would use file mtime
521                headers.push(("Last-Modified".to_string(), "placeholder".to_string()));
522            }
523        }
524
525        headers
526    }
527
528    pub fn get_cors_headers(&self) -> Vec<(String, String)> {
529        let mut headers = Vec::new();
530
531        if !self.access_control.allow_origins.is_empty() {
532            headers.push((
533                "Access-Control-Allow-Origin".to_string(),
534                self.access_control.allow_origins.join(", "),
535            ));
536        }
537
538        if !self.access_control.allow_methods.is_empty() {
539            headers.push((
540                "Access-Control-Allow-Methods".to_string(),
541                self.access_control.allow_methods.join(", "),
542            ));
543        }
544
545        if !self.access_control.allow_headers.is_empty() {
546            headers.push((
547                "Access-Control-Allow-Headers".to_string(),
548                self.access_control.allow_headers.join(", "),
549            ));
550        }
551
552        if self.access_control.allow_credentials {
553            headers.push((
554                "Access-Control-Allow-Credentials".to_string(),
555                "true".to_string(),
556            ));
557        }
558
559        if self.access_control.max_age > 0 {
560            headers.push((
561                "Access-Control-Max-Age".to_string(),
562                self.access_control.max_age.to_string(),
563            ));
564        }
565
566        headers
567    }
568
569    pub fn url(&self) -> String {
570        let protocol = if self.is_ssl_enabled() {
571            "https"
572        } else {
573            "http"
574        };
575        let port_suffix = match (self.is_ssl_enabled(), self.port) {
576            (true, 443) | (false, 80) => String::new(),
577            _ => format!(":{}", self.port),
578        };
579        format!("{}://{}{}", protocol, self.hostname, port_suffix)
580    }
581
582    /// Check if this site handles the given hostname
583    pub fn handles_hostname(&self, hostname: &str) -> bool {
584        // Check primary hostname
585        if self.hostname == hostname {
586            return true;
587        }
588
589        // Check additional hostnames
590        self.hostnames.iter().any(|h| h == hostname)
591    }
592
593    /// Get all hostnames handled by this site (primary + additional)
594    pub fn get_all_hostnames(&self) -> Vec<&str> {
595        let mut hostnames = vec![self.hostname.as_str()];
596        hostnames.extend(self.hostnames.iter().map(|h| h.as_str()));
597        hostnames
598    }
599
600    /// Check if this site handles the given hostname and port combination
601    pub fn handles_hostname_port(&self, hostname: &str, port: u16) -> bool {
602        self.port == port && self.handles_hostname(hostname)
603    }
604}
605
606impl CompressionConfig {
607    fn validate(&self) -> Result<(), Box<dyn std::error::Error>> {
608        if self.level > 9 {
609            return Err("Compression level must be between 0 and 9".into());
610        }
611
612        if self.types.is_empty() && self.enabled {
613            return Err("Compression types cannot be empty when compression is enabled".into());
614        }
615
616        Ok(())
617    }
618}
619
620impl CacheConfig {
621    fn validate(&self) -> Result<(), Box<dyn std::error::Error>> {
622        // Cache configuration is generally permissive
623        // Just ensure max_age values are reasonable
624        if self.max_age_static > 365 * 24 * 3600 {
625            log::warn!("Static cache max_age is very large (> 1 year)");
626        }
627
628        if self.max_age_dynamic > 24 * 3600 {
629            log::warn!("Dynamic cache max_age is very large (> 1 day)");
630        }
631
632        Ok(())
633    }
634}
635
636impl AccessControlConfig {
637    fn validate(&self) -> Result<(), Box<dyn std::error::Error>> {
638        // Validate HTTP methods
639        let valid_methods = [
640            "GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH", "TRACE", "CONNECT",
641        ];
642
643        for method in &self.allow_methods {
644            if !valid_methods.contains(&method.as_str()) {
645                return Err(format!("Invalid HTTP method: {}", method).into());
646            }
647        }
648
649        // Max age should be reasonable
650        if self.max_age > 7 * 24 * 3600 {
651            log::warn!("CORS max_age is very large (> 1 week)");
652        }
653
654        Ok(())
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn test_site_config_validation() {
664        let mut site = SiteConfig {
665            name: "test".to_string(),
666            hostname: "example.com".to_string(),
667            hostnames: vec![],
668            port: 8080,
669            static_dir: "/tmp".to_string(),
670            default: false,
671            api_only: false,
672            headers: HashMap::new(),
673            redirect_to_https: false,
674            index_files: vec![],
675            error_pages: HashMap::new(),
676            compression: CompressionConfig::default(),
677            cache: CacheConfig::default(),
678            access_control: AccessControlConfig::default(),
679            ssl: SiteSslConfig::default(),
680            proxy: ProxyConfig::default(),
681        };
682
683        assert!(site.validate().is_ok());
684
685        // Test invalid hostname
686        site.hostname = "".to_string();
687        assert!(site.validate().is_err());
688
689        // Test invalid port
690        site.hostname = "example.com".to_string();
691        site.port = 0;
692        assert!(site.validate().is_err());
693
694        // Test invalid port range (port 0 is invalid)
695        site.port = 0;
696        assert!(site.validate().is_err());
697    }
698
699    #[test]
700    fn test_hostname_validation() {
701        let site = SiteConfig {
702            name: "test".to_string(),
703            hostname: "localhost".to_string(),
704            hostnames: vec![],
705            port: 8080,
706            static_dir: "/tmp".to_string(),
707            default: false,
708            api_only: false,
709            headers: HashMap::new(),
710            redirect_to_https: false,
711            index_files: vec![],
712            error_pages: HashMap::new(),
713            compression: CompressionConfig::default(),
714            cache: CacheConfig::default(),
715            access_control: AccessControlConfig::default(),
716            ssl: SiteSslConfig::default(),
717            proxy: ProxyConfig::default(),
718        };
719
720        assert!(site.is_valid_hostname());
721
722        let mut invalid_site = site.clone();
723        invalid_site.hostname = "invalid..hostname".to_string();
724        assert!(!invalid_site.is_valid_hostname());
725
726        invalid_site.hostname = "-invalid.hostname".to_string();
727        assert!(!invalid_site.is_valid_hostname());
728    }
729
730    #[test]
731    fn test_compression_config() {
732        let site = SiteConfig {
733            name: "test".to_string(),
734            hostname: "example.com".to_string(),
735            hostnames: vec![],
736            port: 8080,
737            static_dir: "/tmp".to_string(),
738            default: false,
739            api_only: false,
740            headers: HashMap::new(),
741            redirect_to_https: false,
742            index_files: vec![],
743            error_pages: HashMap::new(),
744            compression: CompressionConfig::default(),
745            cache: CacheConfig::default(),
746            access_control: AccessControlConfig::default(),
747            ssl: SiteSslConfig::default(),
748            proxy: ProxyConfig::default(),
749        };
750
751        assert!(site.should_compress("text/html", 2048));
752        assert!(!site.should_compress("text/html", 512)); // Below min_size
753        assert!(!site.should_compress("image/png", 2048)); // Not in types list
754    }
755
756    #[test]
757    fn test_site_url_generation() {
758        let mut site = SiteConfig {
759            name: "test".to_string(),
760            hostname: "example.com".to_string(),
761            hostnames: vec![],
762            port: 8080,
763            static_dir: "/tmp".to_string(),
764            default: false,
765            api_only: false,
766            headers: HashMap::new(),
767            redirect_to_https: false,
768            index_files: vec![],
769            error_pages: HashMap::new(),
770            compression: CompressionConfig::default(),
771            cache: CacheConfig::default(),
772            access_control: AccessControlConfig::default(),
773            ssl: SiteSslConfig::default(),
774            proxy: ProxyConfig::default(),
775        };
776
777        assert_eq!(site.url(), "http://example.com:8080");
778
779        site.ssl.enabled = true;
780        site.port = 443;
781        assert_eq!(site.url(), "https://example.com");
782
783        site.port = 8443;
784        assert_eq!(site.url(), "https://example.com:8443");
785    }
786
787    #[test]
788    fn test_multi_hostname_functionality() {
789        let site = SiteConfig {
790            name: "test".to_string(),
791            hostname: "example.com".to_string(),
792            hostnames: vec!["www.example.com".to_string(), "example.org".to_string()],
793            port: 8080,
794            static_dir: "/tmp".to_string(),
795            default: false,
796            api_only: false,
797            headers: HashMap::new(),
798            redirect_to_https: false,
799            index_files: vec![],
800            error_pages: HashMap::new(),
801            compression: CompressionConfig::default(),
802            cache: CacheConfig::default(),
803            access_control: AccessControlConfig::default(),
804            ssl: SiteSslConfig::default(),
805            proxy: ProxyConfig::default(),
806        };
807
808        // Test hostname handling
809        assert!(site.handles_hostname("example.com"));
810        assert!(site.handles_hostname("www.example.com"));
811        assert!(site.handles_hostname("example.org"));
812        assert!(!site.handles_hostname("different.com"));
813
814        // Test hostname:port combination
815        assert!(site.handles_hostname_port("example.com", 8080));
816        assert!(site.handles_hostname_port("www.example.com", 8080));
817        assert!(site.handles_hostname_port("example.org", 8080));
818        assert!(!site.handles_hostname_port("example.com", 8081));
819        assert!(!site.handles_hostname_port("different.com", 8080));
820
821        // Test getting all hostnames
822        let all_hostnames = site.get_all_hostnames();
823        assert_eq!(all_hostnames.len(), 3);
824        assert!(all_hostnames.contains(&"example.com"));
825        assert!(all_hostnames.contains(&"www.example.com"));
826        assert!(all_hostnames.contains(&"example.org"));
827    }
828
829    #[test]
830    fn test_multi_hostname_ssl_domains() {
831        let mut site = SiteConfig {
832            name: "test".to_string(),
833            hostname: "example.com".to_string(),
834            hostnames: vec!["www.example.com".to_string(), "api.example.com".to_string()],
835            port: 443,
836            static_dir: "/tmp".to_string(),
837            default: false,
838            api_only: false,
839            headers: HashMap::new(),
840            redirect_to_https: false,
841            index_files: vec![],
842            error_pages: HashMap::new(),
843            compression: CompressionConfig::default(),
844            cache: CacheConfig::default(),
845            access_control: AccessControlConfig::default(),
846            ssl: SiteSslConfig::default(),
847            proxy: ProxyConfig::default(),
848        };
849
850        // Enable SSL
851        site.ssl.enabled = true;
852        site.ssl.domains = vec!["cdn.example.com".to_string()];
853
854        // Test SSL domain collection
855        let ssl_domains = site.get_all_ssl_domains();
856        assert_eq!(ssl_domains.len(), 4);
857        assert!(ssl_domains.contains(&"example.com"));
858        assert!(ssl_domains.contains(&"www.example.com"));
859        assert!(ssl_domains.contains(&"api.example.com"));
860        assert!(ssl_domains.contains(&"cdn.example.com"));
861    }
862
863    #[test]
864    fn test_hostname_validation_with_additional_hostnames() {
865        let mut site = SiteConfig {
866            name: "test".to_string(),
867            hostname: "example.com".to_string(),
868            hostnames: vec![
869                "www.example.com".to_string(),
870                "valid.example.org".to_string(),
871            ],
872            port: 8080,
873            static_dir: "/tmp".to_string(),
874            default: false,
875            api_only: false,
876            headers: HashMap::new(),
877            redirect_to_https: false,
878            index_files: vec![],
879            error_pages: HashMap::new(),
880            compression: CompressionConfig::default(),
881            cache: CacheConfig::default(),
882            access_control: AccessControlConfig::default(),
883            ssl: SiteSslConfig::default(),
884            proxy: ProxyConfig::default(),
885        };
886
887        // Valid configuration should pass
888        assert!(site.validate().is_ok());
889
890        // Test invalid additional hostname
891        site.hostnames = vec!["invalid..hostname".to_string()];
892        assert!(site.validate().is_err());
893
894        // Test empty additional hostname
895        site.hostnames = vec!["".to_string()];
896        assert!(site.validate().is_err());
897    }
898}