1const IFTYPE_PROP_VIRTUAL: u32 = 53;
7const IFTYPE_TUNNEL: u32 = 131;
8const IFTYPE_PPP: u32 = 23;
9const IFTYPE_SOFTWARE_LOOPBACK: u32 = 24;
10const IFTYPE_ETHERNET: u32 = 6;
11const IFTYPE_WIFI: u32 = 71;
12
13const NAME_HINTS: [&str; 8] = [
15 "wintun", "wireguard", "tun", "tap", "vpn", "tunnel", "virtual", "ppp",
16];
17
18#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct RouteFact {
21 pub if_index: u32,
22 pub prefix: String,
24 pub metric: u32,
25 pub if_type: u32,
27 pub hardware: Option<bool>,
29 pub is_virtual: Option<bool>,
31 pub media: String,
32 pub alias: String,
33 pub desc: String,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum Placement {
38 Open,
40 InsideExisting { if_index: u32, alias: String, desc: String },
42 WaitExitTun,
44}
45
46#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct Decision {
49 pub placement: Placement,
50 pub reason: String,
51 pub route: Option<RouteFact>,
52}
53
54pub fn prefix_len(prefix: &str) -> u8 {
56 prefix
57 .rsplit_once('/')
58 .and_then(|(_, n)| n.trim().parse::<u8>().ok())
59 .unwrap_or(0)
60}
61
62pub fn is_split_default(prefix: &str) -> bool {
64 let p = prefix.trim();
65 p == "0.0.0.0/1" || p == "128.0.0.0/1"
66}
67
68pub fn decisive_route(routes: &[RouteFact]) -> Option<&RouteFact> {
70 routes
71 .iter()
72 .max_by(|a, b| {
73 prefix_len(&a.prefix)
74 .cmp(&prefix_len(&b.prefix))
75 .then_with(|| b.metric.cmp(&a.metric))
76 })
77}
78
79pub fn tunnel_reason(route: &RouteFact) -> Option<String> {
81 match route.if_type {
82 IFTYPE_PROP_VIRTUAL => return Some("if_type=53 propVirtual".into()),
83 IFTYPE_TUNNEL => return Some("if_type=131 tunnel".into()),
84 IFTYPE_PPP => return Some("if_type=23 ppp".into()),
85 _ => {}
86 }
87 if route.hardware == Some(false) {
88 return Some("hardware_interface=false".into());
89 }
90 if route.is_virtual == Some(true) {
91 return Some("virtual=true".into());
92 }
93 if route.hardware.is_none() && route.if_type != IFTYPE_SOFTWARE_LOOPBACK {
94 return Some("no physical adapter object".into());
95 }
96 if is_split_default(&route.prefix) {
97 return Some(format!("split default route {}", route.prefix));
98 }
99 let media = route.media.to_ascii_lowercase();
100 if !media.is_empty()
101 && media != "802.3"
102 && (media.contains("tunnel") || (media.contains("ip") && !media.contains("802")))
103 {
104 return Some(format!("media={}", route.media));
105 }
106 let blob = format!("{} {}", route.alias, route.desc).to_ascii_lowercase();
107 if route.if_type != IFTYPE_ETHERNET && route.if_type != IFTYPE_WIFI {
108 if let Some(hint) = NAME_HINTS.iter().find(|h| blob.contains(*h)) {
109 return Some(format!("name hint '{hint}'"));
110 }
111 }
112 None
113}
114
115pub fn decide(routes: &[RouteFact], nested_locked: bool) -> Decision {
116 let Some(route) = decisive_route(routes) else {
117 return Decision {
118 placement: if nested_locked { Placement::WaitExitTun } else { Placement::Open },
119 reason: "no route to endpoint".into(),
120 route: None,
121 };
122 };
123 if let Some(why) = tunnel_reason(route) {
124 return Decision {
125 placement: Placement::InsideExisting {
126 if_index: route.if_index,
127 alias: route.alias.clone(),
128 desc: route.desc.clone(),
129 },
130 reason: format!("tunnel: {why}"),
131 route: Some(route.clone()),
132 };
133 }
134 if nested_locked {
137 return Decision {
138 placement: Placement::WaitExitTun,
139 reason: "nested locked; decisive route is physical".into(),
140 route: Some(route.clone()),
141 };
142 }
143 Decision {
144 placement: Placement::Open,
145 reason: "physical: route to endpoint via hardware adapter".into(),
146 route: Some(route.clone()),
147 }
148}
149
150pub fn opens_adapter(placement: &Placement) -> bool {
151 matches!(placement, Placement::Open)
152}
153
154fn opt_bool(s: &str) -> Option<bool> {
155 match s.trim() {
156 "1" | "True" | "true" => Some(true),
157 "0" | "False" | "false" => Some(false),
158 _ => None,
159 }
160}
161
162pub fn parse_route_facts(text: &str) -> Vec<RouteFact> {
165 text.lines()
166 .filter_map(|line| {
167 let line = line.trim_end_matches(['\r', '\n']);
168 if line.trim().is_empty() {
169 return None;
170 }
171 let f: Vec<&str> = line.split('\t').collect();
172 if f.len() < 9 {
173 return None;
174 }
175 Some(RouteFact {
176 if_index: f[0].trim().parse().ok()?,
177 prefix: f[1].trim().to_owned(),
178 metric: f[2].trim().parse().unwrap_or(u32::MAX),
179 if_type: f[3].trim().parse().unwrap_or(0),
180 hardware: opt_bool(f[4]),
181 is_virtual: opt_bool(f[5]),
182 media: f[6].trim().to_owned(),
183 alias: f[7].trim().to_owned(),
184 desc: f[8].trim().to_owned(),
185 })
186 })
187 .collect()
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 fn fact(if_index: u32, prefix: &str, metric: u32, if_type: u32, hw: Option<bool>, alias: &str) -> RouteFact {
195 RouteFact {
196 if_index,
197 prefix: prefix.into(),
198 metric,
199 if_type,
200 hardware: hw,
201 is_virtual: None,
202 media: String::new(),
203 alias: alias.into(),
204 desc: "adapter".into(),
205 }
206 }
207
208 #[test]
209 fn unknown_vpn_name_with_virtual_iftype_is_inside() {
210 let routes = [
211 fact(12, "0.0.0.0/0", 25, IFTYPE_ETHERNET, Some(true), "Ethernet"),
212 fact(30, "0.0.0.0/0", 1, IFTYPE_PROP_VIRTUAL, Some(false), "Zebra Link"),
213 ];
214 let d = decide(&routes, false);
215 assert!(matches!(d.placement, Placement::InsideExisting { if_index: 30, .. }), "{d:?}");
216 }
217
218 #[test]
219 fn split_default_beats_physical_default() {
220 let routes = [
221 fact(12, "0.0.0.0/0", 25, IFTYPE_ETHERNET, Some(true), "Ethernet"),
222 fact(30, "0.0.0.0/1", 5, 0, Some(true), "Unnamed"),
223 ];
224 let d = decide(&routes, false);
225 assert!(matches!(d.placement, Placement::InsideExisting { if_index: 30, .. }), "{d:?}");
226 assert!(d.reason.contains("split"), "{}", d.reason);
227 }
228
229 #[test]
230 fn host_route_is_most_specific() {
231 let routes = [
232 fact(30, "0.0.0.0/1", 5, IFTYPE_TUNNEL, Some(false), "Tun"),
233 fact(12, "198.51.100.7/32", 25, IFTYPE_ETHERNET, Some(true), "Ethernet"),
234 ];
235 let d = decide(&routes, false);
236 assert_eq!(d.placement, Placement::Open, "{d:?}");
237 }
238
239 #[test]
240 fn physical_only_opens_or_waits_when_nested() {
241 let routes = [fact(12, "0.0.0.0/0", 25, IFTYPE_ETHERNET, Some(true), "Ethernet")];
242 assert_eq!(decide(&routes, false).placement, Placement::Open);
243 assert_eq!(decide(&routes, true).placement, Placement::WaitExitTun);
244 }
245
246 #[test]
247 fn missing_adapter_object_counts_as_virtual() {
248 let routes = [fact(44, "0.0.0.0/0", 1, 0, None, "x")];
249 assert!(matches!(decide(&routes, false).placement, Placement::InsideExisting { .. }));
250 }
251
252 #[test]
253 fn name_is_only_a_hint_for_unknown_type() {
254 let mut f = fact(9, "0.0.0.0/0", 1, IFTYPE_ETHERNET, Some(true), "My VPN");
255 assert_eq!(tunnel_reason(&f), None);
256 f.if_type = 0;
257 assert!(tunnel_reason(&f).unwrap().contains("name hint"));
258 }
259
260 #[test]
261 fn no_route_waits_when_nested() {
262 assert_eq!(decide(&[], true).placement, Placement::WaitExitTun);
263 assert_eq!(decide(&[], false).placement, Placement::Open);
264 }
265
266 #[test]
267 fn parses_tab_separated_facts_with_utf8_alias() {
268 let text = "30\t0.0.0.0/1\t5\t53\tFalse\tTrue\t\tТуннель 1\tSome Adapter\r\n12\t0.0.0.0/0\t25\t6\tTrue\tFalse\t802.3\tEthernet\tNIC\n";
269 let r = parse_route_facts(text);
270 assert_eq!(r.len(), 2);
271 assert_eq!(r[0].alias, "Туннель 1");
272 assert_eq!(r[0].hardware, Some(false));
273 assert_eq!(r[0].is_virtual, Some(true));
274 assert_eq!(r[1].if_type, 6);
275 assert!(matches!(decide(&r, false).placement, Placement::InsideExisting { if_index: 30, .. }));
276 }
277
278 #[test]
279 fn opens_adapter_only_for_open() {
280 assert!(opens_adapter(&Placement::Open));
281 assert!(!opens_adapter(&Placement::WaitExitTun));
282 }
283}