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