Skip to main content

tiny_proxy/proxy/
proxy.rs

1use hyper::body::Incoming;
2use hyper::service::service_fn;
3use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
4use hyper_util::client::legacy::connect::HttpConnector;
5use hyper_util::client::legacy::Client;
6use hyper_util::rt::TokioExecutor;
7use hyper_util::rt::TokioIo;
8use std::collections::{HashMap, HashSet};
9use std::net::SocketAddr;
10use std::sync::Arc;
11use std::time::Duration;
12use tokio::net::TcpListener;
13use tokio::sync::{RwLock, Semaphore};
14use tracing::{error, info, warn};
15
16#[cfg(feature = "tls")]
17use crate::proxy::tls::{build_tls_acceptor, listen_http_redirect, listen_tls};
18
19use crate::config::{extract_hostname, resolve_listen_addr, tls_redirect_port, Config};
20use crate::proxy::handler::proxy;
21
22/// HTTP Proxy server that can be embedded into other applications
23///
24/// This struct encapsulates the proxy state and allows programmatic control
25/// over the proxy lifecycle. Configuration is stored in an `Arc<RwLock<Config>>`
26/// so it can be hot-reloaded at runtime (e.g. via the API server).
27///
28/// # Example
29///
30/// ```no_run
31/// use tiny_proxy::{Config, Proxy};
32///
33/// #[tokio::main]
34/// async fn main() -> anyhow::Result<()> {
35///     let config = Config::from_file("file.caddy")?;
36///     let proxy = Proxy::new(config);
37///     proxy.start("127.0.0.1:8080").await?;
38///     Ok(())
39/// }
40/// ```
41///
42/// # Hot-reload Example
43///
44/// ```no_run
45/// use tiny_proxy::{Config, Proxy};
46/// use std::sync::Arc;
47/// use tokio::sync::RwLock;
48///
49/// #[tokio::main]
50/// async fn main() -> anyhow::Result<()> {
51///     let config = Config::from_file("config.caddy")?;
52///     let proxy = Proxy::new(config);
53///
54///     // Get a handle to the shared config for hot-reload
55///     let config_handle = proxy.shared_config();
56///
57///     // Spawn proxy in background
58///     let handle = tokio::spawn(async move {
59///         if let Err(e) = proxy.start("127.0.0.1:8080").await {
60///             eprintln!("Proxy error: {}", e);
61///         }
62///     });
63///
64///     // Later, update config at runtime
65///     let new_config = Config::from_file("updated-config.caddy")?;
66///     {
67///         let mut guard = config_handle.write().await;
68///         *guard = new_config;
69///     }
70///
71///     handle.await?;
72///     Ok(())
73/// }
74/// ```
75pub struct Proxy {
76    config: Arc<RwLock<Config>>,
77    client: Client<HttpsConnector<HttpConnector>, Incoming>,
78    max_concurrency: usize,
79    semaphore: Arc<Semaphore>,
80}
81
82impl Proxy {
83    /// Create a new proxy instance with the given configuration
84    ///
85    /// The configuration is internally wrapped in `Arc<RwLock<Config>>`
86    /// so it can be shared with an API server for hot-reload.
87    ///
88    /// # Arguments
89    ///
90    /// * `config` - Configuration loaded from file or constructed programmatically
91    ///
92    /// # Returns
93    ///
94    /// A new `Proxy` instance ready to be started
95    pub fn new(config: Config) -> Self {
96        let mut http = HttpConnector::new();
97        http.set_keepalive(Some(Duration::from_secs(60)));
98        http.set_nodelay(true);
99        let https = HttpsConnectorBuilder::new()
100            .with_native_roots()
101            .expect("Failed to load native TLS root certificates")
102            .https_or_http()
103            .enable_http1()
104            .wrap_connector(http);
105
106        let client = Client::builder(TokioExecutor::new())
107            .pool_max_idle_per_host(100)
108            .pool_idle_timeout(Duration::from_secs(90))
109            .build::<_, Incoming>(https);
110
111        let max_concurrency = std::env::var("TINY_PROXY_MAX_CONCURRENCY")
112            .ok()
113            .and_then(|v| v.parse().ok())
114            .unwrap_or_else(|| num_cpus::get() * 256);
115
116        let semaphore = Arc::new(Semaphore::new(max_concurrency));
117
118        info!(
119            "Proxy initialized with max_concurrency={} (default: {})",
120            max_concurrency,
121            num_cpus::get() * 256
122        );
123
124        Self {
125            config: Arc::new(RwLock::new(config)),
126            client,
127            max_concurrency,
128            semaphore,
129        }
130    }
131
132    /// Create a new proxy instance from an already shared configuration
133    ///
134    /// Use this when you already have an `Arc<RwLock<Config>>` that is
135    /// shared with an API server or other component.
136    ///
137    /// # Arguments
138    ///
139    /// * `config` - Shared configuration wrapped in `Arc<RwLock<Config>>`
140    pub fn from_shared(config: Arc<RwLock<Config>>) -> Self {
141        let mut http = HttpConnector::new();
142        http.set_keepalive(Some(Duration::from_secs(60)));
143        http.set_nodelay(true);
144        let https = HttpsConnectorBuilder::new()
145            .with_native_roots()
146            .expect("Failed to load native TLS root certificates")
147            .https_or_http()
148            .enable_http1()
149            .wrap_connector(http);
150
151        let client = Client::builder(TokioExecutor::new())
152            .pool_max_idle_per_host(100)
153            .pool_idle_timeout(Duration::from_secs(90))
154            .build::<_, Incoming>(https);
155
156        let max_concurrency = std::env::var("TINY_PROXY_MAX_CONCURRENCY")
157            .ok()
158            .and_then(|v| v.parse().ok())
159            .unwrap_or_else(|| num_cpus::get() * 256);
160
161        let semaphore = Arc::new(Semaphore::new(max_concurrency));
162
163        info!(
164            "Proxy initialized with max_concurrency={} (default: {})",
165            max_concurrency,
166            num_cpus::get() * 256
167        );
168
169        Self {
170            config,
171            client,
172            max_concurrency,
173            semaphore,
174        }
175    }
176
177    /// Start the proxy server on the specified address
178    ///
179    /// This method blocks indefinitely, handling incoming connections.
180    /// To run the proxy in the background, spawn it in a tokio task.
181    ///
182    /// Starts a **single** listener on `addr`. If matching sites use TLS, an HTTPS
183    /// listener is started; otherwise plain HTTP. Does **not** start HTTP→HTTPS
184    /// redirect servers — use [`Self::start_all`] for auto-detect multi-listener mode.
185    ///
186    /// # Arguments
187    ///
188    /// * `addr` - Address to listen on (e.g., "127.0.0.1:8080" or "0.0.0.0:8443")
189    ///
190    /// # Example
191    ///
192    /// ```no_run
193    /// # use tiny_proxy::{Config, Proxy};
194    /// # #[tokio::main]
195    /// # async fn main() -> anyhow::Result<()> {
196    /// # let config = Config::from_file("config.caddy")?;
197    /// # let proxy = Proxy::new(config);
198    /// proxy.start("127.0.0.1:8080").await?;
199    /// # Ok(())
200    /// # }
201    /// ```
202    pub async fn start(&self, addr: &str) -> anyhow::Result<()> {
203        let addr: SocketAddr = addr.parse()?;
204        self.start_with_addr(addr).await
205    }
206
207    /// Start the proxy server with a parsed SocketAddr
208    ///
209    /// Same as [`Self::start`]: one listener on `addr`, HTTPS or HTTP depending on
210    /// site TLS config. No automatic HTTP→HTTPS redirect — see [`Self::start_all`].
211    ///
212    /// # Arguments
213    ///
214    /// * `addr` - Parsed SocketAddr to listen on
215    pub async fn start_with_addr(&self, addr: SocketAddr) -> anyhow::Result<()> {
216        // Check if any site on this address has TLS configured
217        let config_snapshot = self.config.read().await.clone();
218        let tls_sites: Vec<(String, crate::config::TlsConfig)> = config_snapshot
219            .sites
220            .values()
221            .filter(|site| {
222                // Check if the site's address matches the listening addr
223                // Site address can be "host:port" or just ":port"
224                site_addr_matches(&site.address, &addr) && site.tls.is_some()
225            })
226            .filter_map(|site| {
227                // Extract hostname for SNI, TLS config
228                let hostname = extract_hostname(&site.address);
229                site.tls.clone().map(|tls| (hostname.to_string(), tls))
230            })
231            .collect();
232
233        if !tls_sites.is_empty() {
234            #[cfg(feature = "tls")]
235            {
236                self.start_tls(addr, tls_sites).await
237            }
238            #[cfg(not(feature = "tls"))]
239            {
240                anyhow::bail!(
241                    "TLS configuration found for {} but 'tls' feature is disabled. \
242                     Refusing to start as plain HTTP (security risk). \
243                     Rebuild with --features tls or remove 'tls' from config.",
244                    addr
245                );
246            }
247        } else {
248            self.start_http(addr).await
249        }
250    }
251
252    /// Start all listeners defined in the configuration (auto-detect mode).
253    ///
254    /// Scans the config for all unique listen addresses and starts a listener
255    /// for each. TLS sites get HTTPS listeners with SNI; non-TLS sites get HTTP.
256    ///
257    /// For each distinct TLS port, also starts an HTTP→HTTPS redirect listener:
258    /// `redirect_port = tls_port - 443 + 80` (e.g. 443→80, 8443→8080).
259    /// Redirect bind is best-effort: if the redirect port is in use, HTTPS still works.
260    ///
261    /// Unlike [`Self::start`] / [`Self::start_with_addr`], this method spawns multiple
262    /// listeners and redirect servers. Use this when the config defines several site
263    /// addresses (CLI without `--addr`).
264    ///
265    /// This method blocks until all listener tasks finish (typically forever).
266    ///
267    /// # Example
268    ///
269    /// ```no_run
270    /// # use tiny_proxy::{Config, Proxy};
271    /// # #[tokio::main]
272    /// # async fn main() -> anyhow::Result<()> {
273    /// # let config = Config::from_file("config.caddy")?;
274    /// # let proxy = std::sync::Arc::new(Proxy::new(config));
275    /// proxy.start_all().await?;
276    /// # Ok(())
277    /// # }
278    /// ```
279    pub async fn start_all(&self) -> anyhow::Result<()> {
280        let config_snapshot = self.config.read().await.clone();
281
282        // Group sites by resolved listen socket (multiple hostnames may share one port)
283        let mut socket_groups: HashMap<SocketAddr, Vec<&crate::config::SiteConfig>> =
284            HashMap::new();
285        for site in config_snapshot.sites.values() {
286            let listen_addr = resolve_listen_addr(&site.address)?;
287            socket_groups.entry(listen_addr).or_default().push(site);
288        }
289
290        let mut http_handles = Vec::new();
291        let mut tls_redirects: HashSet<(SocketAddr, u16)> = HashSet::new(); // (redirect bind addr, tls_port)
292
293        for (listen_addr, sites) in socket_groups {
294            let tls_sites: Vec<_> = sites.iter().copied().filter(|s| s.tls.is_some()).collect();
295            let has_tls = !tls_sites.is_empty();
296            let has_plain = tls_sites.len() != sites.len();
297
298            if has_tls && has_plain {
299                anyhow::bail!(
300                    "Mixed TLS and non-TLS sites on the same listen address {} is not supported",
301                    listen_addr
302                );
303            }
304
305            if has_tls {
306                #[cfg(feature = "tls")]
307                {
308                    let tls_entries: Vec<(String, crate::config::TlsConfig)> = tls_sites
309                        .iter()
310                        .filter_map(|s| {
311                            let hostname = extract_hostname(&s.address);
312                            s.tls.clone().map(|tls| (hostname.to_string(), tls))
313                        })
314                        .collect();
315
316                    let tls_port = listen_addr.port();
317
318                    let client = self.client.clone();
319                    let config = self.config.clone();
320                    let semaphore = self.semaphore.clone();
321
322                    let acceptor = build_tls_acceptor(&tls_entries, None)?;
323                    info!(
324                        "Starting HTTPS listener on {} ({} domain(s))",
325                        listen_addr,
326                        tls_entries.len()
327                    );
328
329                    let handle = tokio::spawn(async move {
330                        if let Err(e) =
331                            listen_tls(listen_addr, acceptor, semaphore, move |req, remote_addr| {
332                                let client = client.clone();
333                                let config = config.clone();
334                                async move {
335                                    let config_guard = config.read().await;
336                                    let config_snapshot = Arc::new(config_guard.clone());
337                                    drop(config_guard);
338                                    proxy(req, client, config_snapshot, remote_addr, true).await
339                                }
340                            })
341                            .await
342                        {
343                            error!("TLS listener error: {}", e);
344                        }
345                    });
346                    http_handles.push(handle);
347
348                    tls_redirects.insert((
349                        SocketAddr::new(listen_addr.ip(), tls_redirect_port(tls_port)),
350                        tls_port,
351                    ));
352                }
353
354                #[cfg(not(feature = "tls"))]
355                {
356                    anyhow::bail!(
357                        "TLS configuration found for {} but 'tls' feature is disabled. \
358                         Refusing to start as plain HTTP (security risk). \
359                         Rebuild with --features tls or remove 'tls' from config.",
360                        listen_addr
361                    );
362                }
363            } else {
364                let client = self.client.clone();
365                let config = self.config.clone();
366                let semaphore = self.semaphore.clone();
367                let max_concurrency = self.max_concurrency;
368
369                let handle = tokio::spawn(async move {
370                    if let Err(e) =
371                        Self::run_http_loop(listen_addr, client, config, semaphore, max_concurrency)
372                            .await
373                    {
374                        error!("HTTP listener error: {}", e);
375                    }
376                });
377                http_handles.push(handle);
378            }
379        }
380
381        #[cfg(feature = "tls")]
382        for (redirect_addr, tls_port) in tls_redirects {
383            info!(
384                "Starting HTTP→HTTPS redirect on http://{} → :{}",
385                redirect_addr, tls_port
386            );
387            let handle = tokio::spawn(async move {
388                match listen_http_redirect(redirect_addr, tls_port).await {
389                    Ok(()) => {}
390                    Err(e) => {
391                        warn!(
392                            "HTTP redirect on port {} failed (HTTPS on :{} still active): {}",
393                            redirect_addr.port(),
394                            tls_port,
395                            e
396                        );
397                    }
398                }
399            });
400            http_handles.push(handle);
401        }
402
403        if http_handles.is_empty() {
404            warn!("No listeners configured — proxy has no sites");
405            return Ok(());
406        }
407
408        info!(
409            "Started {} listener(s), max concurrency: {} ({})",
410            http_handles.len(),
411            self.max_concurrency,
412            if self.max_concurrency == num_cpus::get() * 256 {
413                "default"
414            } else {
415                "custom"
416            }
417        );
418
419        // Wait for any listener to finish (they run forever, so this blocks indefinitely)
420        // If one fails, the others keep running.
421        for handle in http_handles {
422            if let Err(e) = handle.await {
423                error!("Listener task panicked: {}", e);
424            }
425        }
426
427        Ok(())
428    }
429
430    /// Start a plain HTTP listener on the given address.
431    async fn start_http(&self, addr: SocketAddr) -> anyhow::Result<()> {
432        Self::run_http_loop(
433            addr,
434            self.client.clone(),
435            self.config.clone(),
436            self.semaphore.clone(),
437            self.max_concurrency,
438        )
439        .await
440    }
441
442    /// Core HTTP accept loop — shared between `start_http` and `start_all`.
443    async fn run_http_loop(
444        addr: SocketAddr,
445        client: Client<HttpsConnector<HttpConnector>, Incoming>,
446        config: Arc<RwLock<Config>>,
447        semaphore: Arc<Semaphore>,
448        max_concurrency: usize,
449    ) -> anyhow::Result<()> {
450        let listener = TcpListener::bind(&addr).await?;
451        info!("Tiny Proxy listening on http://{}", addr);
452
453        loop {
454            let (stream, remote_addr) = listener.accept().await?;
455            let io = TokioIo::new(stream);
456            let client = client.clone();
457            let config = config.clone();
458            let semaphore = semaphore.clone();
459
460            match semaphore.try_acquire_owned() {
461                Ok(permit) => {
462                    tokio::task::spawn(async move {
463                        let _permit = permit;
464                        let service = service_fn(move |req| {
465                            let client = client.clone();
466                            let config = config.clone();
467
468                            let config_clone = config.clone();
469                            async move {
470                                let config_guard = config_clone.read().await;
471                                let config_snapshot = Arc::new(config_guard.clone());
472                                drop(config_guard);
473                                proxy(req, client, config_snapshot, remote_addr, false).await
474                            }
475                        });
476
477                        let mut builder = hyper::server::conn::http1::Builder::new();
478                        builder.keep_alive(true).pipeline_flush(false);
479
480                        builder.serve_connection(io, service).await
481                    });
482                }
483                Err(_) => {
484                    warn!(
485                        "Concurrency limit exceeded ({}), rejecting connection",
486                        max_concurrency
487                    );
488                }
489            }
490        }
491    }
492
493    /// Start a TLS listener on the given address with the specified TLS sites.
494    #[cfg(feature = "tls")]
495    async fn start_tls(
496        &self,
497        addr: SocketAddr,
498        tls_sites: Vec<(String, crate::config::TlsConfig)>,
499    ) -> anyhow::Result<()> {
500        let acceptor = build_tls_acceptor(&tls_sites, None)?;
501        info!(
502            "Starting HTTPS listener on https://{} ({} domain(s))",
503            addr,
504            tls_sites.len()
505        );
506
507        let client = self.client.clone();
508        let config = self.config.clone();
509        let semaphore = self.semaphore.clone();
510
511        listen_tls(addr, acceptor, semaphore, move |req, remote_addr| {
512            let client = client.clone();
513            let config = config.clone();
514            async move {
515                let config_guard = config.read().await;
516                let config_snapshot = Arc::new(config_guard.clone());
517                drop(config_guard);
518                proxy(req, client, config_snapshot, remote_addr, true).await
519            }
520        })
521        .await
522    }
523
524    /// Get a reference to the shared configuration handle
525    ///
526    /// This returns a clone of the `Arc<RwLock<Config>>`, allowing
527    /// external code (e.g. an API server) to read and update the
528    /// configuration at runtime.
529    ///
530    /// # Returns
531    ///
532    /// A cloned `Arc<RwLock<Config>>`
533    pub fn shared_config(&self) -> Arc<RwLock<Config>> {
534        self.config.clone()
535    }
536
537    /// Get a snapshot of the current configuration
538    ///
539    /// Reads the current configuration and returns an owned clone.
540    /// This is useful for inspecting config without holding a lock.
541    ///
542    /// # Returns
543    ///
544    /// A cloned `Config`
545    pub async fn config_snapshot(&self) -> Config {
546        self.config.read().await.clone()
547    }
548
549    /// Get current concurrency limit
550    ///
551    /// # Returns
552    ///
553    /// Current maximum number of concurrent connections
554    pub fn max_concurrency(&self) -> usize {
555        self.max_concurrency
556    }
557
558    /// Update concurrency limit at runtime
559    ///
560    /// # Arguments
561    ///
562    /// * `max` - New maximum number of concurrent connections
563    ///
564    /// # Note
565    ///
566    /// This updates the semaphore immediately. New connections will use
567    /// the new limit, but existing connections are not affected.
568    pub fn set_max_concurrency(&mut self, max: usize) {
569        self.max_concurrency = max;
570        self.semaphore = Arc::new(Semaphore::new(max));
571        info!("Max concurrency updated to {}", max);
572    }
573
574    /// Update the configuration at runtime (hot-reload)
575    ///
576    /// Atomically replaces routing configuration. New connections use the updated
577    /// config immediately; in-flight connections keep their original snapshot.
578    ///
579    /// **TLS certificates** are loaded when a listener starts. This method updates
580    /// site routing and directives only — not cert/key files or `TlsAcceptor`.
581    /// Restart the proxy (or TLS listener) to pick up new certificates.
582    ///
583    /// # Arguments
584    ///
585    /// * `config` - New configuration to use
586    pub async fn update_config(&self, config: Config) {
587        let mut guard = self.config.write().await;
588        info!("Configuration updated ({} sites)", config.sites.len());
589        *guard = config;
590    }
591}
592
593/// Check if a site address string matches a SocketAddr.
594///
595/// The site address may be `"host:port"`, `"host"` (no port),
596/// or `":port"` (any host). This function compares ports and,
597/// if the site specifies a hostname (not empty, not `0.0.0.0`),
598/// also compares hostnames.
599fn site_addr_matches(site_address: &str, listen_addr: &SocketAddr) -> bool {
600    let mut parts = site_address.rsplitn(2, ':');
601    let port_str = parts.next().unwrap_or("");
602    let host_str = parts.next().unwrap_or("");
603
604    let site_port: u16 = match port_str.parse() {
605        Ok(p) => p,
606        Err(_) => return false,
607    };
608
609    if site_port != listen_addr.port() {
610        return false;
611    }
612
613    // If site has a specific hostname, check it
614    if host_str.is_empty() || host_str == "0.0.0.0" || host_str == "::" {
615        return true; // wildcard host
616    }
617
618    // Match against listen addr IP
619    // site may have "localhost" → resolve to 127.0.0.1
620    let site_ip = if host_str == "localhost" {
621        std::net::IpAddr::from(std::net::Ipv4Addr::new(127, 0, 0, 1))
622    } else if let Ok(ip) = host_str.parse::<std::net::IpAddr>() {
623        ip
624    } else {
625        // hostname-based (e.g. "example.com:443") — match by port only
626        return true;
627    };
628
629    site_ip == listen_addr.ip()
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use std::collections::HashMap;
636
637    #[test]
638    fn test_proxy_creation() {
639        let config = Config {
640            sites: HashMap::new(),
641        };
642        let proxy = Proxy::new(config);
643        // Can't check sites len synchronously anymore, use snapshot
644        let rt = tokio::runtime::Runtime::new().unwrap();
645        let snapshot = rt.block_on(proxy.config_snapshot());
646        assert_eq!(snapshot.sites.len(), 0);
647    }
648
649    #[tokio::test]
650    async fn test_config_access() {
651        let mut config = Config {
652            sites: HashMap::new(),
653        };
654        config.sites.insert(
655            "localhost:8080".to_string(),
656            crate::config::SiteConfig {
657                address: "localhost:8080".to_string(),
658                directives: vec![],
659                tls: None,
660            },
661        );
662
663        let proxy = Proxy::new(config);
664        let snapshot = proxy.config_snapshot().await;
665        assert_eq!(snapshot.sites.len(), 1);
666        assert!(snapshot.sites.contains_key("localhost:8080"));
667    }
668
669    #[tokio::test]
670    async fn test_config_update() {
671        let config1 = Config {
672            sites: HashMap::new(),
673        };
674        let proxy = Proxy::new(config1);
675        let snapshot = proxy.config_snapshot().await;
676        assert_eq!(snapshot.sites.len(), 0);
677
678        let mut config2 = Config {
679            sites: HashMap::new(),
680        };
681        config2.sites.insert(
682            "test.local".to_string(),
683            crate::config::SiteConfig {
684                address: "test.local".to_string(),
685                directives: vec![],
686                tls: None,
687            },
688        );
689
690        proxy.update_config(config2).await;
691        let snapshot = proxy.config_snapshot().await;
692        assert_eq!(snapshot.sites.len(), 1);
693        assert!(snapshot.sites.contains_key("test.local"));
694    }
695
696    #[tokio::test]
697    async fn test_shared_config_handle() {
698        let config = Config {
699            sites: HashMap::new(),
700        };
701        let proxy = Proxy::new(config);
702
703        let handle = proxy.shared_config();
704
705        // Update via the shared handle
706        {
707            let mut guard = handle.write().await;
708            guard.sites.insert(
709                "shared.local".to_string(),
710                crate::config::SiteConfig {
711                    address: "shared.local".to_string(),
712                    directives: vec![],
713                    tls: None,
714                },
715            );
716        }
717
718        // Verify the proxy sees the update
719        let snapshot = proxy.config_snapshot().await;
720        assert_eq!(snapshot.sites.len(), 1);
721        assert!(snapshot.sites.contains_key("shared.local"));
722    }
723
724    #[test]
725    fn test_from_shared() {
726        let config = Config {
727            sites: HashMap::new(),
728        };
729        let shared = Arc::new(RwLock::new(config));
730        let proxy = Proxy::from_shared(shared.clone());
731
732        // Verify both point to the same config
733        let rt = tokio::runtime::Runtime::new().unwrap();
734        {
735            let mut guard = rt.block_on(shared.write());
736            guard.sites.insert(
737                "from-shared.local".to_string(),
738                crate::config::SiteConfig {
739                    address: "from-shared.local".to_string(),
740                    directives: vec![],
741                    tls: None,
742                },
743            );
744        }
745        let snapshot = rt.block_on(proxy.config_snapshot());
746        assert_eq!(snapshot.sites.len(), 1);
747        assert!(snapshot.sites.contains_key("from-shared.local"));
748    }
749
750    // --- site_addr_matches tests ---
751
752    #[test]
753    fn test_site_addr_matches_localhost() {
754        let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap();
755        assert!(site_addr_matches("localhost:8080", &addr));
756    }
757
758    #[test]
759    fn test_site_addr_matches_ip() {
760        let addr: SocketAddr = "0.0.0.0:443".parse().unwrap();
761        assert!(site_addr_matches("0.0.0.0:443", &addr));
762    }
763
764    #[test]
765    fn test_site_addr_matches_hostname_by_port() {
766        let addr: SocketAddr = "0.0.0.0:443".parse().unwrap();
767        // Domain-based address matches by port only
768        assert!(site_addr_matches("example.com:443", &addr));
769    }
770
771    #[test]
772    fn test_site_addr_matches_port_mismatch() {
773        let addr: SocketAddr = "0.0.0.0:443".parse().unwrap();
774        assert!(!site_addr_matches("example.com:8443", &addr));
775    }
776
777    #[test]
778    fn test_site_addr_matches_wildcard_host() {
779        let addr: SocketAddr = "0.0.0.0:9090".parse().unwrap();
780        assert!(site_addr_matches(":9090", &addr));
781    }
782}