bws_web_server/config/
site.rs

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