Skip to main content

zerobox_network_proxy/
config.rs

1use anyhow::Context;
2use anyhow::Result;
3use anyhow::bail;
4use serde::Deserialize;
5use serde::Deserializer;
6use serde::Serialize;
7use serde::Serializer;
8use std::collections::BTreeMap;
9use std::net::IpAddr;
10use std::net::SocketAddr;
11use std::path::Path;
12use tracing::warn;
13use url::Url;
14use zerobox_utils_absolute_path::AbsolutePathBuf;
15
16#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
17pub struct NetworkProxyConfig {
18    #[serde(default)]
19    pub network: NetworkProxySettings,
20}
21
22/// Variant order encodes effective precedence for duplicate patterns:
23/// `None < Allow < Deny`, so deny wins over allow when entries conflict.
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
25#[serde(rename_all = "lowercase")]
26pub enum NetworkDomainPermission {
27    None,
28    Allow,
29    Deny,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct NetworkDomainPermissionEntry {
34    pub pattern: String,
35    pub permission: NetworkDomainPermission,
36}
37
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct NetworkDomainPermissions {
40    pub entries: Vec<NetworkDomainPermissionEntry>,
41}
42
43impl Serialize for NetworkDomainPermissions {
44    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
45    where
46        S: Serializer,
47    {
48        self.effective_entries()
49            .into_iter()
50            .map(|entry| (entry.pattern, entry.permission))
51            .collect::<BTreeMap<_, _>>()
52            .serialize(serializer)
53    }
54}
55
56impl<'de> Deserialize<'de> for NetworkDomainPermissions {
57    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
58    where
59        D: Deserializer<'de>,
60    {
61        let entries = BTreeMap::<String, NetworkDomainPermission>::deserialize(deserializer)?
62            .into_iter()
63            .map(|(pattern, permission)| NetworkDomainPermissionEntry {
64                pattern,
65                permission,
66            })
67            .collect();
68        Ok(Self { entries })
69    }
70}
71
72impl NetworkDomainPermissions {
73    fn effective_entries(&self) -> Vec<NetworkDomainPermissionEntry> {
74        let mut order = Vec::new();
75        let mut effective_permissions = BTreeMap::new();
76
77        for entry in &self.entries {
78            if !effective_permissions.contains_key(&entry.pattern) {
79                order.push(entry.pattern.clone());
80            }
81
82            let permission = effective_permissions
83                .entry(entry.pattern.clone())
84                .or_insert(entry.permission);
85            if entry.permission > *permission {
86                *permission = entry.permission;
87            }
88        }
89
90        order
91            .into_iter()
92            .filter_map(|pattern| {
93                effective_permissions.remove(&pattern).map(|permission| {
94                    NetworkDomainPermissionEntry {
95                        pattern,
96                        permission,
97                    }
98                })
99            })
100            .collect()
101    }
102}
103
104#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "lowercase")]
106pub enum NetworkUnixSocketPermission {
107    Allow,
108    None,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
112pub struct NetworkUnixSocketPermissions {
113    #[serde(flatten)]
114    pub entries: BTreeMap<String, NetworkUnixSocketPermission>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118#[serde(default)]
119pub struct NetworkProxySettings {
120    #[serde(default)]
121    pub enabled: bool,
122    #[serde(default = "default_proxy_url")]
123    pub proxy_url: String,
124    pub enable_socks5: bool,
125    #[serde(default = "default_socks_url")]
126    pub socks_url: String,
127    pub enable_socks5_udp: bool,
128    pub allow_upstream_proxy: bool,
129    #[serde(default)]
130    pub dangerously_allow_non_loopback_proxy: bool,
131    #[serde(default)]
132    pub dangerously_allow_all_unix_sockets: bool,
133    #[serde(default)]
134    pub mode: NetworkMode,
135    #[serde(default)]
136    pub domains: Option<NetworkDomainPermissions>,
137    #[serde(default)]
138    pub unix_sockets: Option<NetworkUnixSocketPermissions>,
139    pub allow_local_binding: bool,
140    #[serde(default)]
141    pub mitm: bool,
142}
143
144impl Default for NetworkProxySettings {
145    fn default() -> Self {
146        Self {
147            enabled: false,
148            proxy_url: default_proxy_url(),
149            enable_socks5: true,
150            socks_url: default_socks_url(),
151            enable_socks5_udp: true,
152            allow_upstream_proxy: true,
153            dangerously_allow_non_loopback_proxy: false,
154            dangerously_allow_all_unix_sockets: false,
155            mode: NetworkMode::default(),
156            domains: None,
157            unix_sockets: None,
158            allow_local_binding: false,
159            mitm: false,
160        }
161    }
162}
163
164impl NetworkProxySettings {
165    pub fn allowed_domains(&self) -> Option<Vec<String>> {
166        self.domain_entries(NetworkDomainPermission::Allow)
167    }
168
169    pub fn denied_domains(&self) -> Option<Vec<String>> {
170        self.domain_entries(NetworkDomainPermission::Deny)
171    }
172
173    fn domain_entries(&self, permission: NetworkDomainPermission) -> Option<Vec<String>> {
174        self.domains
175            .as_ref()
176            .map(|domains| {
177                domains
178                    .effective_entries()
179                    .iter()
180                    .filter(|entry| entry.permission == permission)
181                    .map(|entry| entry.pattern.clone())
182                    .collect()
183            })
184            .filter(|entries: &Vec<String>| !entries.is_empty())
185    }
186
187    pub fn allow_unix_sockets(&self) -> Vec<String> {
188        self.unix_sockets
189            .as_ref()
190            .map(|unix_sockets| {
191                unix_sockets
192                    .entries
193                    .iter()
194                    .filter(|(_, permission)| {
195                        matches!(permission, NetworkUnixSocketPermission::Allow)
196                    })
197                    .map(|(path, _)| path.clone())
198                    .collect()
199            })
200            .unwrap_or_default()
201    }
202
203    pub fn set_allowed_domains(&mut self, allowed_domains: Vec<String>) {
204        self.set_domain_entries(allowed_domains, NetworkDomainPermission::Allow);
205    }
206
207    pub fn set_denied_domains(&mut self, denied_domains: Vec<String>) {
208        self.set_domain_entries(denied_domains, NetworkDomainPermission::Deny);
209    }
210
211    pub fn upsert_domain_permission(
212        &mut self,
213        host: String,
214        permission: NetworkDomainPermission,
215        normalize: impl Fn(&str) -> String,
216    ) {
217        let mut domains = self.domains.take().unwrap_or_default();
218        let normalized_host = normalize(&host);
219        domains
220            .entries
221            .retain(|entry| normalize(&entry.pattern) != normalized_host);
222        domains.entries.push(NetworkDomainPermissionEntry {
223            pattern: host,
224            permission,
225        });
226        self.domains = (!domains.entries.is_empty()).then_some(domains);
227    }
228
229    pub fn set_allow_unix_sockets(&mut self, allow_unix_sockets: Vec<String>) {
230        self.set_unix_socket_entries(allow_unix_sockets, NetworkUnixSocketPermission::Allow);
231    }
232
233    fn set_domain_entries(&mut self, entries: Vec<String>, permission: NetworkDomainPermission) {
234        let mut domains = self.domains.take().unwrap_or_default();
235        domains
236            .entries
237            .retain(|entry| entry.permission != permission);
238        for entry in entries {
239            if !domains
240                .entries
241                .iter()
242                .any(|existing| existing.pattern == entry && existing.permission == permission)
243            {
244                domains.entries.push(NetworkDomainPermissionEntry {
245                    pattern: entry,
246                    permission,
247                });
248            }
249        }
250        self.domains = (!domains.entries.is_empty()).then_some(domains);
251    }
252
253    fn set_unix_socket_entries(
254        &mut self,
255        entries: Vec<String>,
256        permission: NetworkUnixSocketPermission,
257    ) {
258        let mut unix_sockets = self.unix_sockets.take().unwrap_or_default();
259        unix_sockets
260            .entries
261            .retain(|_, existing| *existing != permission);
262        for entry in entries {
263            unix_sockets.entries.insert(entry, permission);
264        }
265        self.unix_sockets = (!unix_sockets.entries.is_empty()).then_some(unix_sockets);
266    }
267}
268
269#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
270#[serde(rename_all = "lowercase")]
271pub enum NetworkMode {
272    /// Limited (read-only) access: only GET/HEAD/OPTIONS are allowed for HTTP. HTTPS CONNECT is
273    /// blocked unless MITM is enabled so the proxy can enforce method policy on inner requests.
274    /// SOCKS5 remains blocked in limited mode.
275    Limited,
276    /// Full network access: all HTTP methods are allowed, and HTTPS CONNECTs are tunneled without
277    /// MITM interception.
278    #[default]
279    Full,
280}
281
282impl NetworkMode {
283    pub fn allows_method(self, method: &str) -> bool {
284        match self {
285            Self::Full => true,
286            Self::Limited => matches!(method, "GET" | "HEAD" | "OPTIONS"),
287        }
288    }
289}
290
291fn default_proxy_url() -> String {
292    "http://127.0.0.1:3128".to_string()
293}
294
295fn default_socks_url() -> String {
296    "http://127.0.0.1:8081".to_string()
297}
298
299/// Clamp non-loopback bind addresses to loopback unless explicitly allowed.
300fn clamp_non_loopback(
301    addr: SocketAddr,
302    allow_non_loopback: bool,
303    name: &str,
304    override_setting_name: &str,
305) -> SocketAddr {
306    if addr.ip().is_loopback() {
307        return addr;
308    }
309
310    if allow_non_loopback {
311        warn!("DANGEROUS: {name} listening on non-loopback address {addr}");
312        return addr;
313    }
314
315    warn!(
316        "{name} requested non-loopback bind ({addr}); clamping to 127.0.0.1:{port} (set {override_setting_name} to override)",
317        port = addr.port()
318    );
319    SocketAddr::from(([127, 0, 0, 1], addr.port()))
320}
321
322pub(crate) fn clamp_bind_addrs(
323    http_addr: SocketAddr,
324    socks_addr: SocketAddr,
325    cfg: &NetworkProxySettings,
326) -> (SocketAddr, SocketAddr) {
327    let http_addr = clamp_non_loopback(
328        http_addr,
329        cfg.dangerously_allow_non_loopback_proxy,
330        "HTTP proxy",
331        "dangerously_allow_non_loopback_proxy",
332    );
333    let socks_addr = clamp_non_loopback(
334        socks_addr,
335        cfg.dangerously_allow_non_loopback_proxy,
336        "SOCKS5 proxy",
337        "dangerously_allow_non_loopback_proxy",
338    );
339    if cfg.allow_unix_sockets().is_empty() && !cfg.dangerously_allow_all_unix_sockets {
340        return (http_addr, socks_addr);
341    }
342
343    // `x-unix-socket` is intentionally a local escape hatch. If the proxy is reachable from
344    // outside the machine, it can become a remote bridge into local daemons
345    // (e.g. docker.sock). To avoid footguns, enforce loopback binding whenever unix sockets
346    // are enabled.
347    if cfg.dangerously_allow_non_loopback_proxy && !http_addr.ip().is_loopback() {
348        warn!(
349            "unix socket proxying is enabled; ignoring dangerously_allow_non_loopback_proxy and clamping HTTP proxy to loopback"
350        );
351    }
352    if cfg.dangerously_allow_non_loopback_proxy && !socks_addr.ip().is_loopback() {
353        warn!(
354            "unix socket proxying is enabled; ignoring dangerously_allow_non_loopback_proxy and clamping SOCKS5 proxy to loopback"
355        );
356    }
357    (
358        SocketAddr::from(([127, 0, 0, 1], http_addr.port())),
359        SocketAddr::from(([127, 0, 0, 1], socks_addr.port())),
360    )
361}
362
363pub struct RuntimeConfig {
364    pub http_addr: SocketAddr,
365    pub socks_addr: SocketAddr,
366}
367
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub(crate) struct UnixStyleAbsolutePath(String);
370
371impl UnixStyleAbsolutePath {
372    fn parse(value: &str) -> Option<Self> {
373        value.starts_with('/').then(|| Self(value.to_string()))
374    }
375}
376
377#[derive(Debug, Clone, PartialEq, Eq)]
378pub(crate) enum ValidatedUnixSocketPath {
379    Native(AbsolutePathBuf),
380    UnixStyleAbsolute(UnixStyleAbsolutePath),
381}
382
383impl ValidatedUnixSocketPath {
384    pub(crate) fn parse(socket_path: &str) -> Result<Self> {
385        let path = Path::new(socket_path);
386        if path.is_absolute() {
387            let path = AbsolutePathBuf::from_absolute_path(path)
388                .with_context(|| format!("failed to normalize unix socket path {socket_path:?}"))?;
389            return Ok(Self::Native(path));
390        }
391
392        if let Some(path) = UnixStyleAbsolutePath::parse(socket_path) {
393            return Ok(Self::UnixStyleAbsolute(path));
394        }
395
396        bail!("expected an absolute path, got {socket_path:?}");
397    }
398}
399
400pub(crate) fn validate_unix_socket_allowlist_paths(cfg: &NetworkProxyConfig) -> Result<()> {
401    for (index, socket_path) in cfg.network.allow_unix_sockets().iter().enumerate() {
402        ValidatedUnixSocketPath::parse(socket_path)
403            .with_context(|| format!("invalid network.allow_unix_sockets[{index}]"))?;
404    }
405    Ok(())
406}
407
408pub fn resolve_runtime(cfg: &NetworkProxyConfig) -> Result<RuntimeConfig> {
409    validate_unix_socket_allowlist_paths(cfg)?;
410
411    let http_addr = resolve_addr(&cfg.network.proxy_url, /*default_port*/ 3128)
412        .with_context(|| format!("invalid network.proxy_url: {}", cfg.network.proxy_url))?;
413    let socks_addr = resolve_addr(&cfg.network.socks_url, /*default_port*/ 8081)
414        .with_context(|| format!("invalid network.socks_url: {}", cfg.network.socks_url))?;
415    let (http_addr, socks_addr) = clamp_bind_addrs(http_addr, socks_addr, &cfg.network);
416
417    Ok(RuntimeConfig {
418        http_addr,
419        socks_addr,
420    })
421}
422
423fn resolve_addr(url: &str, default_port: u16) -> Result<SocketAddr> {
424    let addr_parts = parse_host_port(url, default_port)?;
425    let host = if addr_parts.host.eq_ignore_ascii_case("localhost") {
426        "127.0.0.1".to_string()
427    } else {
428        addr_parts.host
429    };
430    match host.parse::<IpAddr>() {
431        Ok(ip) => Ok(SocketAddr::new(ip, addr_parts.port)),
432        Err(_) => Ok(SocketAddr::from(([127, 0, 0, 1], addr_parts.port))),
433    }
434}
435
436pub fn host_and_port_from_network_addr(value: &str, default_port: u16) -> String {
437    let trimmed = value.trim();
438    if trimmed.is_empty() {
439        return "<missing>".to_string();
440    }
441
442    let parts = match parse_host_port(trimmed, default_port) {
443        Ok(parts) => parts,
444        Err(_) => {
445            return format_host_and_port(trimmed, default_port);
446        }
447    };
448
449    format_host_and_port(&parts.host, parts.port)
450}
451
452fn format_host_and_port(host: &str, port: u16) -> String {
453    if host.contains(':') {
454        format!("[{host}]:{port}")
455    } else {
456        format!("{host}:{port}")
457    }
458}
459
460#[derive(Debug, Clone, PartialEq, Eq)]
461struct SocketAddressParts {
462    host: String,
463    port: u16,
464}
465
466fn parse_host_port(url: &str, default_port: u16) -> Result<SocketAddressParts> {
467    let trimmed = url.trim();
468    if trimmed.is_empty() {
469        bail!("missing host in network proxy address: {url}");
470    }
471
472    // Avoid treating unbracketed IPv6 literals like "2001:db8::1" as scheme-prefixed URLs.
473    if matches!(trimmed.parse::<IpAddr>(), Ok(IpAddr::V6(_))) && !trimmed.starts_with('[') {
474        return Ok(SocketAddressParts {
475            host: trimmed.to_string(),
476            port: default_port,
477        });
478    }
479
480    // Prefer the standard URL parser when the input is URL-like. Prefix a scheme when absent so
481    // we still accept loose host:port inputs.
482    let candidate = if trimmed.contains("://") {
483        trimmed.to_string()
484    } else {
485        format!("http://{trimmed}")
486    };
487    if let Ok(parsed) = Url::parse(&candidate)
488        && let Some(host) = parsed.host_str()
489    {
490        let host = host.trim_matches(|c| c == '[' || c == ']');
491        if host.is_empty() {
492            bail!("missing host in network proxy address: {url}");
493        }
494        return Ok(SocketAddressParts {
495            host: host.to_string(),
496            port: parsed.port().unwrap_or(default_port),
497        });
498    }
499
500    parse_host_port_fallback(trimmed, default_port)
501}
502
503fn parse_host_port_fallback(input: &str, default_port: u16) -> Result<SocketAddressParts> {
504    let without_scheme = input
505        .split_once("://")
506        .map(|(_, rest)| rest)
507        .unwrap_or(input);
508    let host_port = without_scheme.split('/').next().unwrap_or(without_scheme);
509    let host_port = host_port
510        .rsplit_once('@')
511        .map(|(_, rest)| rest)
512        .unwrap_or(host_port);
513
514    if host_port.starts_with('[')
515        && let Some(end) = host_port.find(']')
516    {
517        let host = &host_port[1..end];
518        let port = host_port[end + 1..]
519            .strip_prefix(':')
520            .and_then(|port| port.parse::<u16>().ok())
521            .unwrap_or(default_port);
522        if host.is_empty() {
523            bail!("missing host in network proxy address: {input}");
524        }
525        return Ok(SocketAddressParts {
526            host: host.to_string(),
527            port,
528        });
529    }
530
531    // Only treat `host:port` as such when there's a single `:`. This avoids
532    // accidentally interpreting unbracketed IPv6 addresses as `host:port`.
533    if host_port.bytes().filter(|b| *b == b':').count() == 1
534        && let Some((host, port)) = host_port.rsplit_once(':')
535    {
536        if host.is_empty() {
537            bail!("missing host in network proxy address: {input}");
538        }
539        return Ok(SocketAddressParts {
540            host: host.to_string(),
541            port: port.parse::<u16>().ok().unwrap_or(default_port),
542        });
543    }
544
545    if host_port.is_empty() {
546        bail!("missing host in network proxy address: {input}");
547    }
548    Ok(SocketAddressParts {
549        host: host_port.to_string(),
550        port: default_port,
551    })
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    use pretty_assertions::assert_eq;
559
560    fn settings_with_unix_sockets(unix_sockets: &[&str]) -> NetworkProxySettings {
561        let mut settings = NetworkProxySettings::default();
562        if !unix_sockets.is_empty() {
563            settings.set_allow_unix_sockets(
564                unix_sockets
565                    .iter()
566                    .map(|path| (*path).to_string())
567                    .collect(),
568            );
569        }
570        settings
571    }
572
573    #[test]
574    fn network_proxy_settings_default_matches_local_use_baseline() {
575        assert_eq!(
576            NetworkProxySettings::default(),
577            NetworkProxySettings {
578                enabled: false,
579                proxy_url: "http://127.0.0.1:3128".to_string(),
580                enable_socks5: true,
581                socks_url: "http://127.0.0.1:8081".to_string(),
582                enable_socks5_udp: true,
583                allow_upstream_proxy: true,
584                dangerously_allow_non_loopback_proxy: false,
585                dangerously_allow_all_unix_sockets: false,
586                mode: NetworkMode::Full,
587                domains: None,
588                unix_sockets: None,
589                allow_local_binding: false,
590                mitm: false,
591            }
592        );
593    }
594
595    #[test]
596    fn partial_network_config_uses_struct_defaults_for_missing_fields() {
597        let config: NetworkProxyConfig = serde_json::from_str(
598            r#"{
599                "network": {
600                    "enabled": true
601                }
602            }"#,
603        )
604        .unwrap();
605        let expected = NetworkProxySettings {
606            enabled: true,
607            ..NetworkProxySettings::default()
608        };
609
610        assert_eq!(config.network, expected);
611    }
612
613    #[test]
614    fn set_allowed_domains_preserves_existing_deny_for_same_pattern() {
615        let mut settings = NetworkProxySettings::default();
616        settings.set_denied_domains(vec!["example.com".to_string()]);
617
618        settings.set_allowed_domains(vec!["example.com".to_string()]);
619
620        assert_eq!(settings.allowed_domains(), None);
621        assert_eq!(
622            settings.denied_domains(),
623            Some(vec!["example.com".to_string()])
624        );
625    }
626
627    #[test]
628    fn network_domain_permissions_serialize_to_effective_map_shape() {
629        let mut settings = NetworkProxySettings::default();
630        settings.set_denied_domains(vec!["example.com".to_string()]);
631        settings.set_allowed_domains(vec!["example.com".to_string()]);
632        let config = NetworkProxyConfig { network: settings };
633
634        let value = serde_json::to_value(&config).unwrap();
635
636        assert_eq!(
637            value,
638            serde_json::json!({
639                "network": {
640                    "enabled": false,
641                    "proxy_url": "http://127.0.0.1:3128",
642                    "enable_socks5": true,
643                    "socks_url": "http://127.0.0.1:8081",
644                    "enable_socks5_udp": true,
645                    "allow_upstream_proxy": true,
646                    "dangerously_allow_non_loopback_proxy": false,
647                    "dangerously_allow_all_unix_sockets": false,
648                    "mode": "full",
649                    "domains": {
650                        "example.com": "deny",
651                    },
652                    "unix_sockets": null,
653                    "allow_local_binding": false,
654                    "mitm": false,
655                }
656            })
657        );
658    }
659
660    #[test]
661    fn parse_host_port_defaults_for_empty_string() {
662        assert!(parse_host_port("", /*default_port*/ 1234).is_err());
663    }
664
665    #[test]
666    fn parse_host_port_defaults_for_whitespace() {
667        assert!(parse_host_port("   ", /*default_port*/ 5555).is_err());
668    }
669
670    #[test]
671    fn parse_host_port_parses_host_port_without_scheme() {
672        assert_eq!(
673            parse_host_port("127.0.0.1:8080", /*default_port*/ 3128).unwrap(),
674            SocketAddressParts {
675                host: "127.0.0.1".to_string(),
676                port: 8080,
677            }
678        );
679    }
680
681    #[test]
682    fn parse_host_port_parses_host_port_with_scheme_and_path() {
683        assert_eq!(
684            parse_host_port(
685                "http://example.com:8080/some/path",
686                /*default_port*/ 3128
687            )
688            .unwrap(),
689            SocketAddressParts {
690                host: "example.com".to_string(),
691                port: 8080,
692            }
693        );
694    }
695
696    #[test]
697    fn parse_host_port_strips_userinfo() {
698        assert_eq!(
699            parse_host_port(
700                "http://user:pass@host.example:5555",
701                /*default_port*/ 3128
702            )
703            .unwrap(),
704            SocketAddressParts {
705                host: "host.example".to_string(),
706                port: 5555,
707            }
708        );
709    }
710
711    #[test]
712    fn parse_host_port_parses_ipv6_with_brackets() {
713        assert_eq!(
714            parse_host_port("http://[::1]:9999", /*default_port*/ 3128).unwrap(),
715            SocketAddressParts {
716                host: "::1".to_string(),
717                port: 9999,
718            }
719        );
720    }
721
722    #[test]
723    fn parse_host_port_does_not_treat_unbracketed_ipv6_as_host_port() {
724        assert_eq!(
725            parse_host_port("2001:db8::1", /*default_port*/ 3128).unwrap(),
726            SocketAddressParts {
727                host: "2001:db8::1".to_string(),
728                port: 3128,
729            }
730        );
731    }
732
733    #[test]
734    fn parse_host_port_falls_back_to_default_port_when_port_is_invalid() {
735        assert_eq!(
736            parse_host_port("example.com:notaport", /*default_port*/ 3128).unwrap(),
737            SocketAddressParts {
738                host: "example.com".to_string(),
739                port: 3128,
740            }
741        );
742    }
743
744    #[test]
745    fn host_and_port_from_network_addr_defaults_for_empty_string() {
746        assert_eq!(
747            host_and_port_from_network_addr("", /*default_port*/ 1234),
748            "<missing>"
749        );
750    }
751
752    #[test]
753    fn host_and_port_from_network_addr_formats_ipv6() {
754        assert_eq!(
755            host_and_port_from_network_addr("http://[::1]:8080", /*default_port*/ 3128),
756            "[::1]:8080"
757        );
758    }
759
760    #[test]
761    fn resolve_addr_maps_localhost_to_loopback() {
762        assert_eq!(
763            resolve_addr("localhost", /*default_port*/ 3128).unwrap(),
764            "127.0.0.1:3128".parse::<SocketAddr>().unwrap()
765        );
766    }
767
768    #[test]
769    fn resolve_addr_parses_ip_literals() {
770        assert_eq!(
771            resolve_addr("1.2.3.4", /*default_port*/ 80).unwrap(),
772            "1.2.3.4:80".parse::<SocketAddr>().unwrap()
773        );
774    }
775
776    #[test]
777    fn resolve_addr_parses_ipv6_literals() {
778        assert_eq!(
779            resolve_addr("http://[::1]:8080", /*default_port*/ 3128).unwrap(),
780            "[::1]:8080".parse::<SocketAddr>().unwrap()
781        );
782    }
783
784    #[test]
785    fn resolve_addr_falls_back_to_loopback_for_hostnames() {
786        assert_eq!(
787            resolve_addr("http://example.com:5555", /*default_port*/ 3128).unwrap(),
788            "127.0.0.1:5555".parse::<SocketAddr>().unwrap()
789        );
790    }
791
792    #[test]
793    fn clamp_bind_addrs_allows_non_loopback_when_enabled() {
794        let cfg = NetworkProxySettings {
795            dangerously_allow_non_loopback_proxy: true,
796            ..Default::default()
797        };
798        let http_addr = "0.0.0.0:3128".parse::<SocketAddr>().unwrap();
799        let socks_addr = "0.0.0.0:8081".parse::<SocketAddr>().unwrap();
800
801        let (http_addr, socks_addr) = clamp_bind_addrs(http_addr, socks_addr, &cfg);
802
803        assert_eq!(http_addr, "0.0.0.0:3128".parse::<SocketAddr>().unwrap());
804        assert_eq!(socks_addr, "0.0.0.0:8081".parse::<SocketAddr>().unwrap());
805    }
806
807    #[test]
808    fn clamp_bind_addrs_forces_loopback_when_unix_sockets_enabled() {
809        let cfg = {
810            let mut settings = settings_with_unix_sockets(&["/tmp/docker.sock"]);
811            settings.dangerously_allow_non_loopback_proxy = true;
812            settings
813        };
814        let http_addr = "0.0.0.0:3128".parse::<SocketAddr>().unwrap();
815        let socks_addr = "0.0.0.0:8081".parse::<SocketAddr>().unwrap();
816
817        let (http_addr, socks_addr) = clamp_bind_addrs(http_addr, socks_addr, &cfg);
818
819        assert_eq!(http_addr, "127.0.0.1:3128".parse::<SocketAddr>().unwrap());
820        assert_eq!(socks_addr, "127.0.0.1:8081".parse::<SocketAddr>().unwrap());
821    }
822
823    #[test]
824    fn clamp_bind_addrs_forces_loopback_when_all_unix_sockets_enabled() {
825        let cfg = NetworkProxySettings {
826            dangerously_allow_non_loopback_proxy: true,
827            dangerously_allow_all_unix_sockets: true,
828            ..Default::default()
829        };
830        let http_addr = "0.0.0.0:3128".parse::<SocketAddr>().unwrap();
831        let socks_addr = "0.0.0.0:8081".parse::<SocketAddr>().unwrap();
832
833        let (http_addr, socks_addr) = clamp_bind_addrs(http_addr, socks_addr, &cfg);
834
835        assert_eq!(http_addr, "127.0.0.1:3128".parse::<SocketAddr>().unwrap());
836        assert_eq!(socks_addr, "127.0.0.1:8081".parse::<SocketAddr>().unwrap());
837    }
838
839    #[test]
840    fn resolve_runtime_rejects_relative_allow_unix_sockets_entries() {
841        let cfg = NetworkProxyConfig {
842            network: settings_with_unix_sockets(&["relative.sock"]),
843        };
844
845        let err = match resolve_runtime(&cfg) {
846            Ok(runtime) => panic!(
847                "relative allow_unix_sockets should fail, but resolve_runtime succeeded: {:?}",
848                runtime.http_addr
849            ),
850            Err(err) => err,
851        };
852        assert!(
853            err.to_string().contains("network.allow_unix_sockets[0]"),
854            "error should point at the invalid allow_unix_sockets entry: {err:#}"
855        );
856    }
857
858    #[test]
859    fn resolve_runtime_accepts_unix_style_absolute_allow_unix_sockets_entries() {
860        let cfg = NetworkProxyConfig {
861            network: settings_with_unix_sockets(&["/private/tmp/example.sock"]),
862        };
863
864        assert!(
865            resolve_runtime(&cfg).is_ok(),
866            "unix-style absolute allow_unix_sockets entry should be accepted"
867        );
868    }
869}