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;
11const IFTYPE_WIFI: u32 = 71;
12
13/// Name fragments used only as a secondary hint.
14const NAME_HINTS: [&str; 8] = [
15    "wintun", "wireguard", "tun", "tap", "vpn", "tunnel", "virtual", "ppp",
16];
17
18/// One route candidate toward the C2 endpoint with facts about its interface.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct RouteFact {
21    pub if_index: u32,
22    /// Destination prefix of the route, e.g. `0.0.0.0/0`, `0.0.0.0/1`, `x.x.x.x/32`.
23    pub prefix: String,
24    pub metric: u32,
25    /// IANA ifType (`InterfaceType` of Get-NetAdapter / `InterfaceType` of MIB). 0 = unknown.
26    pub if_type: u32,
27    /// `HardwareInterface` of Get-NetAdapter. `None` = no adapter object (hidden/virtual driver).
28    pub hardware: Option<bool>,
29    /// `Virtual` of Get-NetAdapter, when present.
30    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    /// Traffic to C2 leaves through a physical adapter: a dedicated adapter may be created.
39    Open,
40    /// Traffic to C2 already rides another tunnel: run WG in userspace inside it, no adapter.
41    InsideExisting { if_index: u32, alias: String, desc: String },
42    /// Nested mode is locked but no tunnel carries traffic right now: wait.
43    WaitExitTun,
44}
45
46/// Why the decision was made (logged verbatim).
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct Decision {
49    pub placement: Placement,
50    pub reason: String,
51    pub route: Option<RouteFact>,
52}
53
54/// Prefix length of `a.b.c.d/n`; unparseable = 0.
55pub 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
62/// `0.0.0.0/1` or `128.0.0.0/1` (typical VPN split-default).
63pub 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
68/// Most specific route wins; ties broken by lowest metric.
69pub 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
79/// Facts that make an interface a tunnel. Returns the reason when it is one.
80pub 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    // A tunnel anywhere in the list that is not decisive still means a VPN is
135    // around; with nested locked we wait for it instead of opening our own.
136    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
162/// Parse tab-separated lines:
163/// `if_index \t prefix \t metric \t if_type \t hardware \t virtual \t media \t alias \t desc`
164pub 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}