Skip to main content

gate4agent_node_core/
kit_wireguard_placement.rs

1//! WireGuard placement on Windows, decided by facts about the interface that
2//! carries traffic to the C2 endpoint (not by adapter names).
3//! Pure logic; tested on every target.
4
5/// IANA ifType values that mean "not a physical NIC".
6const 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/// One route candidate toward the C2 endpoint with facts about its interface.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct RouteFact {
15    pub if_index: u32,
16    /// Destination prefix of the route, e.g. `0.0.0.0/0`, `0.0.0.0/1`, `x.x.x.x/32`.
17    pub prefix: String,
18    pub metric: u32,
19    /// IANA ifType (`InterfaceType` of Get-NetAdapter / `InterfaceType` of MIB). 0 = unknown.
20    pub if_type: u32,
21    /// `HardwareInterface` of Get-NetAdapter. `None` = no adapter object (hidden/virtual driver).
22    pub hardware: Option<bool>,
23    /// `Virtual` of Get-NetAdapter, when present.
24    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    /// Traffic to C2 leaves through a physical adapter: a dedicated adapter may be created.
33    Open,
34    /// Traffic to C2 already rides another tunnel: run WG in userspace inside it, no adapter.
35    InsideExisting { if_index: u32, alias: String, desc: String },
36    /// Nested mode is locked but no tunnel carries traffic right now: wait.
37    WaitExitTun,
38}
39
40/// Why the decision was made (logged verbatim).
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct Decision {
43    pub placement: Placement,
44    pub reason: String,
45    pub route: Option<RouteFact>,
46}
47
48/// Prefix length of `a.b.c.d/n`; unparseable = 0.
49pub 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
56/// `0.0.0.0/1` or `128.0.0.0/1` (typical VPN split-default).
57pub 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
62/// Most specific route wins; ties broken by lowest metric.
63pub 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
73/// Facts that make an interface a tunnel. Returns the reason when it is one.
74pub 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    // A tunnel anywhere in the list that is not decisive still means a VPN is
123    // around; with nested locked we wait for it instead of opening our own.
124    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
150/// Parse tab-separated lines:
151/// `if_index \t prefix \t metric \t if_type \t hardware \t virtual \t media \t alias \t desc`
152pub 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}