Skip to main content

markon_core/
net.rs

1use local_ip_address::list_afinet_netifas;
2use serde::{Deserialize, Serialize};
3use std::collections::HashSet;
4use std::net::IpAddr;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum BindHostKind {
9    Localhost,
10    AllInterfaces,
11    Interface,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct BindHostOption {
16    pub address: String,
17    pub kind: BindHostKind,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub interface: Option<String>,
20}
21
22impl BindHostOption {
23    fn localhost() -> Self {
24        Self {
25            address: "127.0.0.1".to_string(),
26            kind: BindHostKind::Localhost,
27            interface: None,
28        }
29    }
30
31    fn all_interfaces() -> Self {
32        Self {
33            address: "0.0.0.0".to_string(),
34            kind: BindHostKind::AllInterfaces,
35            interface: None,
36        }
37    }
38}
39
40fn is_bindable_interface_ip(ip: &IpAddr) -> bool {
41    match ip {
42        IpAddr::V4(ipv4) => !ipv4.is_loopback() && !ipv4.is_link_local() && !ipv4.is_unspecified(),
43        // The rest of the app still formats host + port as `host:port`, which
44        // does not work for bare IPv6 literals. Keep the surfaced choices to
45        // IPv4 until the bind pipeline gains proper `[addr]:port` handling.
46        IpAddr::V6(_) => false,
47    }
48}
49
50fn collect_bind_hosts_from_iter<I>(ifaces: I) -> Vec<BindHostOption>
51where
52    I: IntoIterator<Item = (String, IpAddr)>,
53{
54    let mut hosts = vec![
55        BindHostOption::localhost(),
56        BindHostOption::all_interfaces(),
57    ];
58    let mut seen: HashSet<String> = hosts.iter().map(|host| host.address.clone()).collect();
59
60    for (interface, ip) in ifaces {
61        if !is_bindable_interface_ip(&ip) {
62            continue;
63        }
64        let address = ip.to_string();
65        if !seen.insert(address.clone()) {
66            continue;
67        }
68        hosts.push(BindHostOption {
69            address,
70            kind: BindHostKind::Interface,
71            interface: Some(interface),
72        });
73    }
74
75    hosts
76}
77
78pub fn available_bind_hosts() -> Vec<BindHostOption> {
79    match list_afinet_netifas() {
80        Ok(ifaces) => collect_bind_hosts_from_iter(ifaces),
81        Err(_) => collect_bind_hosts_from_iter(std::iter::empty()),
82    }
83}
84
85/// True when `host` can still be bound right now — wildcard / loopback are
86/// always OK, otherwise the address must be present on a current network
87/// interface. Pass `hosts` so callers that already enumerated don't pay twice.
88pub fn host_in_list(host: &str, hosts: &[BindHostOption]) -> bool {
89    let h = host.trim();
90    if matches!(
91        h,
92        "" | "0.0.0.0" | "::" | "[::]" | "127.0.0.1" | "::1" | "[::1]"
93    ) {
94        return true;
95    }
96    let bare = h.trim_start_matches('[').trim_end_matches(']');
97    hosts
98        .iter()
99        .any(|opt| opt.address == h || opt.address == bare)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
106
107    #[test]
108    fn bind_hosts_always_start_with_local_and_all_interfaces() {
109        let hosts = collect_bind_hosts_from_iter(std::iter::empty());
110        assert_eq!(
111            hosts,
112            vec![
113                BindHostOption::localhost(),
114                BindHostOption::all_interfaces()
115            ]
116        );
117    }
118
119    #[test]
120    fn host_in_list_passes_wildcard_and_loopback() {
121        let hosts = collect_bind_hosts_from_iter(std::iter::empty());
122        assert!(host_in_list("0.0.0.0", &hosts));
123        assert!(host_in_list("::", &hosts));
124        assert!(host_in_list("127.0.0.1", &hosts));
125        assert!(host_in_list("::1", &hosts));
126        assert!(!host_in_list("192.168.1.20", &hosts));
127    }
128
129    #[test]
130    fn host_in_list_matches_specific_interface_address() {
131        let hosts = collect_bind_hosts_from_iter([(
132            "en0".to_string(),
133            IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)),
134        )]);
135        assert!(host_in_list("192.168.1.20", &hosts));
136        assert!(!host_in_list("192.168.99.99", &hosts));
137    }
138
139    #[test]
140    fn bind_hosts_filter_non_bindable_ipv4_and_dedupe_results() {
141        let hosts = collect_bind_hosts_from_iter([
142            ("lo0".to_string(), IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
143            (
144                "en0".to_string(),
145                IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)),
146            ),
147            (
148                "en1".to_string(),
149                IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)),
150            ),
151            (
152                "en2".to_string(),
153                IpAddr::V4(Ipv4Addr::new(169, 254, 10, 20)),
154            ),
155            ("awdl0".to_string(), IpAddr::V6(Ipv6Addr::LOCALHOST)),
156            ("bridge0".to_string(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))),
157        ]);
158
159        assert_eq!(
160            hosts,
161            vec![
162                BindHostOption::localhost(),
163                BindHostOption::all_interfaces(),
164                BindHostOption {
165                    address: "192.168.1.20".to_string(),
166                    kind: BindHostKind::Interface,
167                    interface: Some("en0".to_string()),
168                },
169            ]
170        );
171    }
172}